Compare commits
116 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 | |||
| 0b69adf8c9 | |||
| 31c65fb9fd | |||
| 4ee0923721 | |||
| 4b89743404 | |||
| 6e03093ce9 | |||
| 51d6168697 | |||
| 46fc1d4fa4 | |||
| 5d5dee0bbf | |||
| 4f5ce4c750 | |||
| dc17824e84 | |||
| 0fe5359299 | |||
| 563d9f52db | |||
| 732d00c4ad | |||
| 500a8c5340 | |||
| c6379c04f6 | |||
| 01e97848a7 | |||
| 8aaa3331d7 | |||
| d10a572b50 | |||
| a87c73401b | |||
| 8ca6d08133 | |||
| cd273505af | |||
| bdc5e8331f | |||
| 330bcfe124 | |||
| c137d9e02e | |||
| ea8b94acc7 | |||
| 4bbee57d41 | |||
| 43c54923f8 | |||
| a910633c64 | |||
| 899525a75c | |||
| 0a139193b4 | |||
| 0da80849d4 | |||
| 014b11abd2 | |||
| 6ff463b7ef | |||
| 8bb0aabb51 | |||
| 27b4292c2f | |||
| 0b5a441a5d | |||
| 3877359b42 | |||
| 769d15fd86 | |||
| 6317a27378 | |||
| 4fa4c152a5 | |||
| ec23b2455a | |||
| 7f2cccf317 | |||
| a5fae8cf55 | |||
| 191d398604 |
@@ -3,6 +3,8 @@
|
||||
# =========================================================
|
||||
**/target/
|
||||
dependency-reduced-pom.xml
|
||||
# Generierte Flat-POM-Dateien des flatten-maven-plugin (CI-friendly Versioning)
|
||||
**/.flattened-pom.xml
|
||||
|
||||
# =========================================================
|
||||
# Eclipse / IDE
|
||||
|
||||
@@ -11,9 +11,18 @@ Ab V2.0 wird die Anwendung um eine **lokale JavaFX-Desktop-GUI** erweitert. Die
|
||||
@docs/specs/meilensteine-v2_0.md
|
||||
|
||||
Für die Umsetzung ist zusätzlich immer das aktuell aktive Arbeitspaket unter `docs/workpackages/` maßgeblich.
|
||||
Dateinamensschema: `M9 - Arbeitspakete.md`, `M10 - Arbeitspakete.md`, … `M13 - Arbeitspakete.md`
|
||||
Dateinamensschema: `M9 - Arbeitspakete.md`, `M10 - Arbeitspakete.md`, … `M13 - Arbeitspakete.md`, `M14_-_Arbeitspakete.md`, `M15_-_Arbeitspakete.md`.
|
||||
Nicht raten, wenn Dokumente fehlen, unklar sind oder sich widersprechen.
|
||||
|
||||
## Modulare Architektur-Übersichten
|
||||
Detailwissen über Pakete, Schlüsselklassen, Ports und Bootstrap-Verdrahtung ist in drei modularen Übersichtsdokumenten unter `docs/architecture/` ausgelagert. Wer in einem bestimmten Modul arbeitet, liest diese Datei zusätzlich zu CLAUDE.md:
|
||||
|
||||
- `docs/architecture/domain-overview.md` – `pdf-umbenenner-domain` und `pdf-umbenenner-application`: Domänenmodell, Inbound- und Outbound-Ports, Application-Services.
|
||||
- `docs/architecture/gui-overview.md` – `pdf-umbenenner-adapter-in-gui`: Workspace-/Tab-Struktur, View-Modelle, GUI-interne Ports, JavaFX-Threading-Modell.
|
||||
- `docs/architecture/adapter-overview.md` – `pdf-umbenenner-adapter-out`, `pdf-umbenenner-adapter-in-cli`, `pdf-umbenenner-bootstrap`: konkrete Outbound-Adapter, CLI-Einstiegspunkt, Verdrahtungslogik und Provider-Auswahl.
|
||||
|
||||
Für Arbeit ausschließlich in einem dieser Bereiche genügt CLAUDE.md plus die jeweils passende Übersichtsdatei.
|
||||
|
||||
## Priorisierung der Regeln
|
||||
Die Dokumente haben folgende feste Bedeutung:
|
||||
|
||||
@@ -47,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
|
||||
- kein Webserver
|
||||
- kein Applikationsserver
|
||||
- keine Dauerlauf-Anwendung
|
||||
- kein interner Scheduler
|
||||
- keine Dauerlauf-Anwendung (Ausnahme: GUI-Modus mit aktivem Scheduler, s. Scheduler-Ausnahme)
|
||||
- kein interner Scheduler (Ausnahme: optionaler GUI-Scheduler ab V3.2, s. Scheduler-Ausnahme)
|
||||
- das Shade-JAR bleibt das primäre Distributionsartefakt
|
||||
- zusätzlicher nativer Windows-Installer (MSI) ab V3.0 via Maven-Profil `release` (jpackage, WiX Toolset 3.x im PATH erforderlich); der Normalbuild `mvn clean verify` bleibt vom Profil unberührt und benötigt kein WiX
|
||||
- Log4j2 für Logging
|
||||
@@ -68,9 +77,28 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
|
||||
- `pdf-umbenenner-application`
|
||||
- `pdf-umbenenner-adapter-in-cli`
|
||||
- `pdf-umbenenner-adapter-in-gui`
|
||||
- `pdf-umbenenner-adapter-in-scheduler`
|
||||
- `pdf-umbenenner-adapter-out`
|
||||
- `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
|
||||
- Strikte **hexagonale Architektur / Ports and Adapters**
|
||||
- Abhängigkeiten zeigen immer **nach innen**
|
||||
@@ -138,30 +166,11 @@ V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben.
|
||||
|
||||
Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technische Tests) ist abgeschlossen.
|
||||
|
||||
**V2.9 ist abgeschlossen.** Der Tab „Verarbeitungslauf" wurde erweitert um:
|
||||
**V2.9 ist abgeschlossen.** Der Tab „Verarbeitungslauf" wurde erweitert um eine integrierte PDF-Vorschau (Lazy-Rendering direkt über PDFBox, In-Memory-Cache, Seitennavigation) sowie einen editierbaren Dateiname-Bereich mit Live-Validierung, Dirty-State-Dialog und atomarer Dateisystem-/DB-Transaktion inklusive Rollback und Fingerprint-basierter Konfliktauflösung. Die zugehörigen neuen Ports, Use Cases und Adapter sind in den modularen Architektur-Übersichten beschrieben.
|
||||
|
||||
- **Integrierte PDF-Vorschau** (`PdfPreviewPane`) mit Lazy-Rendering, In-Memory-Cache und
|
||||
Seitennavigation. Das Rendering erfolgt direkt über PDFBox
|
||||
(`PDFRenderer.renderImageWithDPI` + `SwingFXUtils.toFXImage`); eine externe PDFViewFX-Abhängigkeit
|
||||
wird nicht mehr verwendet.
|
||||
- **Editierbarer Dateiname-Bereich** (`FileNameEditorPane`) mit Live-Validierung, Dirty-State-Dialog
|
||||
bei Zeilen-/Tabwechsel, Schließen und Laufstart sowie atomarer Dateisystem- und DB-Transaktion
|
||||
inkl. Rollback und Fingerprint-basierter Konfliktauflösung.
|
||||
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.
|
||||
|
||||
Neue Architekturkomponenten in V2.9:
|
||||
|
||||
- Outbound-Port `TargetFileRenamePort` (`pdf-umbenenner-application`)
|
||||
- Application-Use-Case `ManualFileRenameUseCase` / `DefaultManualFileRenameUseCase`
|
||||
- Adapter-Out `FilesystemTargetFileRenameAdapter` (`pdf-umbenenner-adapter-out`)
|
||||
- GUI-interner Port `GuiManualFileRenamePort` (`pdf-umbenenner-adapter-in-gui`)
|
||||
|
||||
Weitere Verhaltensänderungen:
|
||||
|
||||
- Die GUI startet **maximiert** (Vollbild); `stage.setMaximized(true)` in `PdfUmbenennerGuiApplication`.
|
||||
- Beim Start wird die **zuletzt geladene Konfigurationsdatei** automatisch geladen
|
||||
(gespeichert in `java.util.prefs.Preferences` unter Schlüssel `lastConfigPath`,
|
||||
umgesetzt in `GuiConfigurationEditorWorkspace.autoLoadLastConfiguration()`).
|
||||
Existiert die Datei 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.
|
||||
|
||||
@@ -252,6 +261,13 @@ Bestehende Kommentare mit solchen Bezeichnern, die durch eigene Änderungen ber
|
||||
- Keine stillen Änderungen am bestehenden headless Batch-Betrieb
|
||||
- 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
|
||||
Ein Arbeitspaket ist erst fertig, wenn:
|
||||
- der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist
|
||||
@@ -306,6 +322,8 @@ Verbindlich zweckmäßige Parameter:
|
||||
- `log.ai.sensitive` – sensible KI-Logausgabe freischalten (Boolean, Default: `false`)
|
||||
- `runtime.lock.file` – Lock-Datei (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:
|
||||
|
||||
@@ -344,7 +362,7 @@ Verbindlicher Ablauf:
|
||||
- keine OCR innerhalb der Java-Anwendung
|
||||
- keine DMS-Funktionalität
|
||||
- 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 neuen Bibliotheken oder Frameworks ohne klare Notwendigkeit und Begründung
|
||||
- **keine** automatische Fallback-Umschaltung zwischen KI-Providern
|
||||
|
||||
Vendored
+194
@@ -0,0 +1,194 @@
|
||||
// Jenkins-Pipeline für den PDF KI Renamer
|
||||
// Läuft auf einem Linux-Container (Synology NAS).
|
||||
// Der MSI-Build ist Windows-only (jpackage + WiX Toolset 3.x). Jenkins läuft im
|
||||
// Linux-Container auf Synology NAS und kann kein MSI erzeugen. Der MSI-Build
|
||||
// wird bewusst manuell auf der Windows-Entwicklungsmaschine ausgeführt:
|
||||
// .\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests
|
||||
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
|
||||
tools {
|
||||
maven 'maven-3'
|
||||
}
|
||||
|
||||
// MAJOR und MINOR werden manuell als Jenkins-Parameter gepflegt.
|
||||
// BUILD_NUMBER wird automatisch durch Jenkins vergeben.
|
||||
// Die resultierende Versionsnummer lautet: MAJOR.MINOR.BUILD_NUMBER
|
||||
parameters {
|
||||
string(name: 'MAJOR', defaultValue: '3', description: 'SemVer MAJOR (manuell)')
|
||||
string(name: 'MINOR', defaultValue: '0', description: 'SemVer MINOR (manuell)')
|
||||
}
|
||||
|
||||
stages {
|
||||
|
||||
stage('Version bestimmen') {
|
||||
steps {
|
||||
script {
|
||||
def isManual = !currentBuild.getBuildCauses('hudson.model.Cause$UserIdCause').isEmpty()
|
||||
def jenkinsHome = env.JENKINS_HOME ?: '/var/jenkins_home'
|
||||
def safeJobName = env.JOB_NAME.replaceAll(/[^A-Za-z0-9._-]/, '_')
|
||||
def stateDir = "${jenkinsHome}/version-state"
|
||||
def stateFile = "${stateDir}/${safeJobName}.properties"
|
||||
|
||||
if (isManual) {
|
||||
env.EFFECTIVE_MAJOR = params.MAJOR
|
||||
env.EFFECTIVE_MINOR = params.MINOR
|
||||
|
||||
sh """
|
||||
mkdir -p '${stateDir}'
|
||||
cat > '${stateFile}' <<'EOF'
|
||||
MAJOR=${params.MAJOR}
|
||||
MINOR=${params.MINOR}
|
||||
EOF
|
||||
"""
|
||||
|
||||
echo "Manueller Build erkannt. Version gespeichert: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||
} else {
|
||||
def stateExists = (sh(script: "[ -f '${stateFile}' ]", returnStatus: true) == 0)
|
||||
|
||||
if (stateExists) {
|
||||
env.EFFECTIVE_MAJOR = sh(
|
||||
script: "grep '^MAJOR=' '${stateFile}' | cut -d= -f2-",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
env.EFFECTIVE_MINOR = sh(
|
||||
script: "grep '^MINOR=' '${stateFile}' | cut -d= -f2-",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
echo "Automatischer Build erkannt. Gespeicherte Version verwendet: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||
} else {
|
||||
env.EFFECTIVE_MAJOR = params.MAJOR
|
||||
env.EFFECTIVE_MINOR = params.MINOR
|
||||
|
||||
echo "Automatischer Build ohne gespeicherten Stand. Fallback auf Parameter: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||
}
|
||||
}
|
||||
|
||||
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||
}
|
||||
}
|
||||
} // stage: Version bestimmen
|
||||
|
||||
stage('Maven Build') {
|
||||
steps {
|
||||
catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') {
|
||||
// -Drevision übergibt die vollständige Versionsnummer an Maven.
|
||||
// Das flatten-maven-plugin im Parent-POM löst ${revision} in
|
||||
// allen installierten POMs auf.
|
||||
sh "mvn clean verify -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER}"
|
||||
}
|
||||
}
|
||||
} // stage: Maven Build
|
||||
|
||||
stage('SonarQube Analyse') {
|
||||
steps {
|
||||
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
|
||||
withSonarQubeEnv('SonarQube') {
|
||||
sh "mvn sonar:sonar -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} -Dsonar.projectKey=pdf-umbenenner -Dsonar.projectName='PDF KI Renamer'"
|
||||
}
|
||||
}
|
||||
}
|
||||
} // stage: SonarQube Analyse
|
||||
|
||||
stage('Publish PIT Coverage') {
|
||||
steps {
|
||||
recordCoverage(
|
||||
tools: [[
|
||||
parser: 'PIT',
|
||||
pattern: '**/target/pit-reports/mutations.xml'
|
||||
]],
|
||||
id: 'pit',
|
||||
name: 'PIT Mutation Coverage',
|
||||
failOnError: true
|
||||
)
|
||||
}
|
||||
} // stage: Publish PIT Coverage
|
||||
|
||||
stage('Archive JAR') {
|
||||
steps {
|
||||
// Bash wird explizit erzwungen, weil Jenkins-Agenten standardmäßig
|
||||
// sh (dash) verwenden, das kein mapfile kennt. mapfile zählt exakt
|
||||
// die gefundenen Shade-JARs und bricht ab, wenn nicht genau eines vorhanden ist.
|
||||
sh '''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
mapfile -t JARS < <(find pdf-umbenenner-bootstrap/target \
|
||||
-maxdepth 1 -name "pdf-umbenenner-bootstrap-*.jar" \
|
||||
! -name "*-sources.jar" ! -name "*-javadoc.jar")
|
||||
|
||||
test "${#JARS[@]}" -eq 1 \
|
||||
|| { echo "FEHLER: Erwartet genau 1 Shade-JAR, gefunden: ${#JARS[@]}"; exit 1; }
|
||||
|
||||
JAR_NAME="pdf-ki-renamer-${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}.jar"
|
||||
cp "${JARS[0]}" "$JAR_NAME"
|
||||
echo "Shade-JAR archiviert als: $JAR_NAME"
|
||||
'''
|
||||
archiveArtifacts artifacts: 'pdf-ki-renamer-*.jar', fingerprint: true
|
||||
}
|
||||
} // stage: Archive JAR
|
||||
|
||||
stage('Artefakt ablegen') {
|
||||
steps {
|
||||
sh '''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_DIR="/builds/${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
cp pdf-ki-renamer-*.jar "$BUILD_DIR/"
|
||||
echo "Artefakt abgelegt unter: $BUILD_DIR"
|
||||
'''
|
||||
}
|
||||
} // stage: Artefakt ablegen
|
||||
|
||||
stage('Berichte veröffentlichen') {
|
||||
steps {
|
||||
junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true
|
||||
|
||||
recordCoverage(
|
||||
tools: [[parser: 'JACOCO', pattern: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate/jacoco.xml']],
|
||||
enabledForFailure: true
|
||||
)
|
||||
|
||||
publishHTML(target: [
|
||||
reportName: 'JaCoCo HTML Report',
|
||||
reportDir: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate',
|
||||
reportFiles: 'index.html',
|
||||
keepAll: true,
|
||||
alwaysLinkToLastBuild: true,
|
||||
allowMissing: true
|
||||
])
|
||||
}
|
||||
} // stage: Berichte veröffentlichen
|
||||
|
||||
stage('Aufräumen') {
|
||||
steps {
|
||||
sh '''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
rm -f pdf-ki-renamer-*.jar
|
||||
echo "Aufräumen abgeschlossen."
|
||||
'''
|
||||
}
|
||||
} // stage: Aufräumen
|
||||
|
||||
} // stages
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} erfolgreich abgeschlossen."
|
||||
}
|
||||
failure {
|
||||
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} fehlgeschlagen."
|
||||
}
|
||||
always {
|
||||
deleteDir()
|
||||
}
|
||||
}
|
||||
|
||||
} // pipeline
|
||||
@@ -0,0 +1,356 @@
|
||||
# Architektur-Übersicht: Adapter-Out, CLI & Bootstrap
|
||||
|
||||
Diese Datei beschreibt die drei Module `pdf-umbenenner-adapter-out`, `pdf-umbenenner-adapter-in-cli`
|
||||
und `pdf-umbenenner-bootstrap`: ihren Zweck, ihre Paketstruktur, die wichtigsten Klassen und die
|
||||
Verdrahtungslogik beim Programmstart. Sie richtet sich an Entwickler, die in einem dieser Module
|
||||
arbeiten wollen und noch keinen Überblick über das Projekt haben. Domain- und Application-Schicht
|
||||
(Port-Verträge, fachliche Domänenobjekte, Use-Case-Interfaces) sind nicht Gegenstand dieses
|
||||
Dokuments – sie sind in `docs/architecture/domain-overview.md` beschrieben. GUI-interne Ports und
|
||||
die Struktur des GUI-Adapters finden sich in `docs/architecture/gui-overview.md`. Die hexagonale
|
||||
Abhängigkeitsrichtung ist strikt: Adapter kennen Domain und Application, nicht umgekehrt. Adapter
|
||||
dürfen außerdem nicht direkt voneinander abhängen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Modulzweck
|
||||
|
||||
### pdf-umbenenner-adapter-out
|
||||
|
||||
Enthält alle Outbound-Adapter-Implementierungen, also die konkreten technischen Lösungen für
|
||||
sämtliche Outbound-Ports der Application. Dazu gehören: Dateisystemzugriff, PDF-Textextraktion
|
||||
via PDFBox, SQLite-Persistenz (Schema, Repositories, Unit of Work), HTTP-Clients für zwei
|
||||
KI-Provider-Familien (OpenAI-kompatibel und Anthropic nativ), Properties-Konfiguration inklusive
|
||||
Legacy-Migration, dateibasierter Run-Lock sowie Systemuhr und SHA-256-Fingerprint.
|
||||
|
||||
### pdf-umbenenner-adapter-in-cli
|
||||
|
||||
Schlanker Inbound-Adapter für den kopflosen Batch-Betrieb. Enthält genau eine Klasse
|
||||
(`SchedulerBatchCommand`), die den CLI-Einstiegspunkt bildet und ausschließlich über das
|
||||
Inbound-Port-Interface an die Application delegiert. Keine eigene Fachlogik.
|
||||
|
||||
### pdf-umbenenner-bootstrap
|
||||
|
||||
Composition Root der Anwendung. Verantwortlich für: CLI-Argument-Parsing,
|
||||
Konfigurationsauflösung und -validierung, Aufbau des vollständigen Objektgraphen (manuell, ohne
|
||||
DI-Framework), Auswahl der aktiven KI-Adapter-Implementierung, Dispatch auf GUI- oder
|
||||
Headless-Pfad sowie Exit-Code-Ableitung. Bootstrap ist die einzige Stelle, an der alle Module
|
||||
zusammengeführt werden.
|
||||
|
||||
---
|
||||
|
||||
## 2. Paketstruktur
|
||||
|
||||
### pdf-umbenenner-adapter-out
|
||||
|
||||
Wurzelpaket: `de.gecheckt.pdf.umbenenner.adapter.out`
|
||||
|
||||
| Unterpaket | Inhalt |
|
||||
|-------------------------|-------------------------------------------------------------------------------------|
|
||||
| `.ai` | HTTP-Adapter für OpenAI-kompatible Schnittstelle und Anthropic Messages API |
|
||||
| `.clock` | Systemuhr-Adapter (`Instant.now()`) |
|
||||
| `.configuration` | Properties-Laden, Multi-Provider-Parsing/-Validierung, Legacy-Migration |
|
||||
| `.fingerprint` | SHA-256-Inhalts-Fingerprint |
|
||||
| `.lock` | Dateibasierter Run-Lock |
|
||||
| `.modelcatalog` | HTTP-Modellabruf für den GUI-Konfigurationseditor |
|
||||
| `.pathcheck` | Pfadprüfung für den GUI-Editor |
|
||||
| `.pdfextraction` | PDFBox-3.x-Adapter: Textextraktion und Seitenanzahl |
|
||||
| `.prompt` | Prompt-Template-Lader |
|
||||
| `.resourcecreation` | Anlegen von Ordnern und Dateien (korrigierende technische Tests) |
|
||||
| `.sourcedocument` | Quellordner-Scanner (nicht rekursiv) |
|
||||
| `.sqlite` | Schema-Initialisierung, Repositories, Unit of Work |
|
||||
| `.targetcopy` | Zielkopie via Temp-Datei und atomarem Move |
|
||||
| `.targetfolder` | Kollisionsfreier Zieldateiname, Umbenennung bestehender Zieldateien |
|
||||
| `.validation` | API-Key-Auflösung aus Umgebungsvariablen (GUI-Editor) |
|
||||
| `.bootstrap.validation` | `StartConfiguration`-Validierung vor Prozessstart |
|
||||
|
||||
### pdf-umbenenner-adapter-in-cli
|
||||
|
||||
Wurzelpaket: `de.gecheckt.pdf.umbenenner.adapter.in.cli`
|
||||
|
||||
Enthält ausschließlich `SchedulerBatchCommand` sowie die zugehörige `package-info.java`.
|
||||
|
||||
### pdf-umbenenner-bootstrap
|
||||
|
||||
Wurzelpaket: `de.gecheckt.pdf.umbenenner.bootstrap`
|
||||
|
||||
| Unterpaket | Inhalt |
|
||||
|---------------------|-------------------------------------------------------------------------------------------------------|
|
||||
| *(Wurzel)* | `PdfUmbenennerApplication` (main), `BootstrapRunner`, `AiProviderSelector` |
|
||||
| `.adapter` | Bootstrap-interne Adapter: `Log4jProcessingLogger`, `GuiConfigurationPropertiesWriter`, `AiModelCatalogDispatcher` |
|
||||
| `.singleinstance` | `SingleInstanceGuard` – Einzelinstanz-Schutz via Loopback-ServerSocket |
|
||||
| `.startup` | `StartupMode`, `StartupArguments`, `CliArgumentParser` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Schlüsselklassen
|
||||
|
||||
Die folgenden Klassen sind für das Verständnis der drei Module zentral. FQN-Kürzel: `...` steht
|
||||
jeweils für das Wurzelpaket des Moduls.
|
||||
|
||||
### Adapter-Out
|
||||
|
||||
#### KI-Adapter
|
||||
|
||||
- **`...ai.OpenAiHttpAdapter`** – implementiert `AiInvocationPort` für OpenAI-kompatible Endpunkte.
|
||||
POST `{baseUrl}/v1/chat/completions`, Bearer-Authentifizierung, extrahiert
|
||||
`choices[0].message.content`, klassifiziert HTTP-Fehler und Timeouts als
|
||||
`AiInvocationTechnicalFailure`.
|
||||
|
||||
- **`...ai.AnthropicClaudeHttpAdapter`** – implementiert `AiInvocationPort` für die native
|
||||
Anthropic Messages API. POST `/v1/messages`, Header `x-api-key` und `anthropic-version`,
|
||||
konkateniert `text`-Content-Blöcke aus dem Antwort-Array.
|
||||
|
||||
Beide Adapter liefern denselben Domain-Typ (`NamingProposal`) und enthalten keinerlei
|
||||
provider-spezifische Typen in öffentlichen Signaturen. Welche Implementierung aktiv ist, entscheidet
|
||||
ausschließlich der Bootstrap (→ `AiProviderSelector`).
|
||||
|
||||
#### Modell-Katalog (GUI)
|
||||
|
||||
- **`...modelcatalog.ClaudeModelCatalogAdapter`** – `AiModelCatalogPort` für Claude,
|
||||
GET `/v1/models` mit `x-api-key`.
|
||||
|
||||
- **`...modelcatalog.OpenAiCompatibleModelCatalogAdapter`** – `AiModelCatalogPort` für
|
||||
OpenAI-kompatibel, GET `/v1/models` mit Bearer.
|
||||
|
||||
#### PDF-Extraktion
|
||||
|
||||
- **`...pdfextraction.PdfTextExtractionPortAdapter`** – PDFBox-3.x-Adapter. Alle technischen
|
||||
Fehler werden als `PdfExtractionTechnicalError` zurückgegeben; es werden keine Exceptions
|
||||
propagiert.
|
||||
|
||||
#### SQLite
|
||||
|
||||
- **`...sqlite.SqliteSchemaInitializationAdapter`** – Flyway-basierte Schema-Initialisierung
|
||||
mit `V1__initial_schema.sql`. Drei-Fall-Strategie: leere Datenbank (Flyway führt das Skript
|
||||
vollständig aus), bestehender Datenbestand ohne Flyway-History (Schema-Prüfung, datiertes
|
||||
Backup, dann Baseline-Eintrag ohne Skriptausführung), regulärer Folgestart mit Flyway-History
|
||||
(idempotenter Lauf). Foreign-Key-Durchsetzung via `SQLiteConfig.enforceForeignKeys(true)` auf
|
||||
DataSource-Ebene, sodass jede neue Verbindung automatisch `PRAGMA foreign_keys = ON` erhält.
|
||||
|
||||
- **`...sqlite.SqliteUnitOfWorkAdapter`** – implementiert `UnitOfWorkPort`. Setzt
|
||||
`autoCommit=false`, führt atomare Commits durch, rollt bei Fehlern zurück. Die innere
|
||||
`TransactionOperations`-Implementierung wurde um `resetDocumentStatusForRetry(DocumentFingerprint)`
|
||||
erweitert: setzt feldgenau `overall_status = 'READY_FOR_AI'`, `content_error_count = 0`,
|
||||
`transient_error_count = 0`, `last_failure_instant = NULL`; alle anderen Felder und alle
|
||||
`processing_attempt`-Einträge bleiben unangetastet.
|
||||
|
||||
- **`...sqlite.SqliteDocumentRecordRepositoryAdapter`** – Stammsatz pro SHA-256-Fingerprint
|
||||
(Gesamtstatus, Fehlerzähler, Zieldateiname usw.).
|
||||
|
||||
- **`...sqlite.SqliteProcessingAttemptRepositoryAdapter`** – Versuchshistorie, referenziert
|
||||
über Fingerprint. Enthält u. a. Provider-Identifikator, Modellname, Prompt-Identifikator,
|
||||
KI-Rohantwort und finalen Zieldateinamen.
|
||||
|
||||
- **`...sqlite.SqliteHistoryQueryAdapter`** – implementiert `HistoryQueryPort`. Kapselt alle
|
||||
lesenden Datenbankoperationen für den Historien-Tab: Übersicht (`loadOverview` mit
|
||||
Sortierung `updated_at DESC, fingerprint ASC`, LIMIT 501-Strategie, case-insensitive
|
||||
Freitextsuche via `LOWER()` mit Sonderzeichen-Escape für `%` und `_`), Stammsatz-Lookup
|
||||
(`findRecordByFingerprint`) und Versuchshistorie (`findAttemptsByFingerprint`).
|
||||
|
||||
#### Konfiguration
|
||||
|
||||
- **`...configuration.PropertiesConfigurationPortAdapter`** – implementiert `ConfigurationPort`.
|
||||
Lädt `config/application.properties` (oder einen `--config`-Override), parst via
|
||||
`MultiProviderConfigurationParser`, löst API-Keys aus Umgebungsvariablen
|
||||
(`OPENAI_COMPATIBLE_API_KEY`, `ANTHROPIC_API_KEY`).
|
||||
|
||||
- **`...configuration.LegacyConfigurationMigrator`** – erkennt alte Flat-Key-Konfigurationen
|
||||
(Schlüssel wie `api.baseUrl`, `api.model`), legt eine `.bak`-Sicherung an und überführt den
|
||||
Inhalt in das aktuelle Multi-Provider-Schema.
|
||||
|
||||
#### Prompt-Adapter
|
||||
|
||||
- **`...prompt.FilesystemPromptPortAdapter`** – implementiert `PromptPort`. Lädt das
|
||||
Prompt-Template aus einer externen Datei und leitet den Identifikator aus dem Dateinamen ab.
|
||||
Die neue Methode `savePrompt(String content)` schreibt den Inhalt atomar: temporäre Datei
|
||||
im selben Verzeichnis anlegen (gleiche Partition), Inhalt in UTF-8 schreiben, dann
|
||||
`ATOMIC_MOVE` zur Zieldatei. Kein stiller Fallback bei `AtomicMoveNotSupportedException`.
|
||||
Der Pfad stammt aus der Adapter-internen Konfiguration, nicht aus dem Port-Aufruf.
|
||||
|
||||
#### Laufzeitinfrastruktur
|
||||
|
||||
- **`...lock.FilesystemRunLockPortAdapter`** – Lock-Datei mit PID-Inhalt. Wirft
|
||||
`RunLockUnavailableException`, wenn die Datei bereits vorhanden ist. Release löscht die Datei
|
||||
(best-effort).
|
||||
|
||||
- **`...clock.SystemClockAdapter`** – delegiert an `Instant.now()`.
|
||||
|
||||
- **`...fingerprint.Sha256FingerprintAdapter`** – SHA-256 über den Rohdatei-Inhalt. Fehler als
|
||||
`FingerprintTechnicalError`.
|
||||
|
||||
#### Zieldatei
|
||||
|
||||
- **`...targetcopy.FilesystemTargetFileCopyAdapter`** – kopiert die Quelldatei zunächst in eine
|
||||
`.tmp`-Datei, dann atomarer Move (Fallback: Standard-Move). Die Quelldatei wird in keinem Fall
|
||||
verändert.
|
||||
|
||||
- **`...targetfolder.FilesystemTargetFolderAdapter`** – ermittelt einen kollisionsfreien
|
||||
Zieldateinamen mit `(1)`, `(2)`-Suffix. Erkennt inhaltsidentische Duplikate via SHA-256.
|
||||
|
||||
#### Validierung vor Prozessstart
|
||||
|
||||
- **`...bootstrap.validation.StartConfigurationValidator`** – validiert die geladene
|
||||
`StartConfiguration` auf Pflichtfelder, Wertebereiche, URI-Syntax und Pfadbedingungen.
|
||||
Wird im Bootstrap-Headless-Pfad unmittelbar nach dem Laden der Konfiguration aufgerufen.
|
||||
|
||||
---
|
||||
|
||||
### Adapter-In-CLI
|
||||
|
||||
- **`...adapter.in.cli.SchedulerBatchCommand`** – einziger Inbound-Adapter für den Headless-Betrieb.
|
||||
Nimmt einen `BatchRunContext` entgegen, delegiert an `BatchRunProcessingUseCase.execute()` und
|
||||
gibt `BatchRunOutcome` zurück. Enthält keine eigene Fachlogik; die Verdrahtung mit dem
|
||||
Use-Case-Interface erfolgt ausschließlich im Bootstrap.
|
||||
|
||||
---
|
||||
|
||||
### Bootstrap
|
||||
|
||||
- **`...bootstrap.PdfUmbenennerApplication`** – `main`-Methode. Parst CLI-Argumente via
|
||||
`CliArgumentParser`, bricht bei ungültiger Verwendung mit Exit-Code 1 ab, delegiert an
|
||||
`BootstrapRunner.run()` und ruft abschließend `System.exit()` mit dem zurückgegebenen Code auf.
|
||||
|
||||
- **`...bootstrap.BootstrapRunner`** – Herzstück der Verdrahtung. Baut den Objektgraph für
|
||||
Headless- und GUI-Pfad, dispatcht über `StartupMode`, enthält `buildProductionBatchUseCase()`
|
||||
und `runHeadlessBatch()` als zentrale Kompositionsmethoden, liefert den Exit-Code zurück.
|
||||
|
||||
- **`...bootstrap.AiProviderSelector`** – einzige Stelle, an der `AiProviderFamily` auf eine
|
||||
konkrete `AiInvocationPort`-Implementierung abgebildet wird:
|
||||
`OPENAI_COMPATIBLE` → `OpenAiHttpAdapter`, `CLAUDE` → `AnthropicClaudeHttpAdapter`.
|
||||
|
||||
- **`...bootstrap.startup.CliArgumentParser`** – parst `--headless` und `--config <Pfad>` zu einem
|
||||
typsicheren `StartupArgumentsParseResult` (sealed: `Valid` / `Invalid`).
|
||||
|
||||
- **`...bootstrap.singleinstance.SingleInstanceGuard`** – bindet einen Loopback-ServerSocket auf
|
||||
Port 47832. Wirft `AnotherInstanceRunningException`, wenn der Port bereits belegt ist. Ein
|
||||
Shutdown-Hook gibt den Socket frei.
|
||||
|
||||
- **`...bootstrap.adapter.AiModelCatalogDispatcher`** – Bootstrap-interner Dispatcher für die GUI.
|
||||
Routet `AiModelCatalogPort`-Aufrufe anhand des `providerIdentifier` an den Claude- oder
|
||||
OpenAI-kompatiblen Modell-Katalog-Adapter. Thread-safe.
|
||||
|
||||
- **`...bootstrap.ApplicationVersionProvider`** – statische Hilfsklasse ohne Zustand. Liest
|
||||
`Implementation-Version` aus dem Paket-Manifest via `getClass().getPackage().getImplementationVersion()`.
|
||||
Fallback `"dev"` bei IDE-Start und ungepacktem Betrieb (kein Manifest-Eintrag vorhanden).
|
||||
Der aufgelöste Wert wird im GUI-Pfad in `GuiStartupContext.applicationVersion` eingesetzt.
|
||||
|
||||
- **`...bootstrap.adapter.Log4jProcessingLogger`** – implementiert `ProcessingLogger` auf Basis
|
||||
von Log4j2. Unterdrückt sensitive KI-Inhalte, wenn `AiContentSensitivity.PROTECT_SENSITIVE_CONTENT`
|
||||
gesetzt ist.
|
||||
|
||||
- **`...bootstrap.adapter.GuiConfigurationPropertiesWriter`** – schreibt die im GUI-Editor
|
||||
bearbeitete Konfiguration als normalisierte `application.properties` zurück auf das Dateisystem.
|
||||
|
||||
---
|
||||
|
||||
## 4. Verdrahtungslogik in Bootstrap
|
||||
|
||||
Die folgende Sequenz beschreibt den Ablauf von `main()` bis zum Start des eigentlichen Adapters.
|
||||
Der Objektgraph wird ausschließlich durch manuelle `new`-Aufrufe aufgebaut; es wird kein
|
||||
DI-Framework verwendet.
|
||||
|
||||
**Argument-Parsing**
|
||||
- `PdfUmbenennerApplication.main()` → `CliArgumentParser.parse(args)`
|
||||
- Ergebnis `Invalid` → Exit-Code 1, keine weiteren Schritte
|
||||
|
||||
**Einzelinstanz-Schutz**
|
||||
- `BootstrapRunner.run()` → `SingleInstanceGuard.acquire()`
|
||||
- `AnotherInstanceRunningException` → Exit-Code 1; im GUI-Modus zusätzlich ein Swing-Warndialog
|
||||
|
||||
**Modus-Dispatch**
|
||||
- `BootstrapRunner.run()` wertet `startupArguments.mode()` aus:
|
||||
- `HEADLESS` → `runHeadlessBatch()`
|
||||
- `GUI` → `startGuiMode()`
|
||||
|
||||
**Konfigurationsauflösung (Headless-Pfad)**
|
||||
- Prüfung, ob `--config`-Datei existiert (Fehler → Exit-Code 1)
|
||||
- `LegacyConfigurationMigrator.migrateIfLegacy()` bei erkannter Legacy-Form
|
||||
- `PropertiesConfigurationPortAdapter` lädt und parst die Properties
|
||||
- `StartConfigurationValidator` validiert die geladene `StartConfiguration`
|
||||
- Validierungsfehler → Exit-Code 1
|
||||
|
||||
**KI-Provider-Auswahl**
|
||||
- Innerhalb von `buildProductionBatchUseCase()`:
|
||||
`multiProviderConfiguration().activeProviderFamily()` → `AiProviderSelector.select(family, providerConfig)`
|
||||
- Ergebnis: genau eine `AiInvocationPort`-Instanz
|
||||
|
||||
**Objektgraph-Aufbau (Headless)**
|
||||
- Erzeugte Instanzen (Reihenfolge nach Abhängigkeit): `Sha256FingerprintAdapter`,
|
||||
`SqliteDocumentRecordRepositoryAdapter`, `SqliteProcessingAttemptRepositoryAdapter`,
|
||||
`SqliteUnitOfWorkAdapter`, `FilesystemTargetFolderAdapter`, `FilesystemTargetFileCopyAdapter`,
|
||||
`FilesystemPromptPortAdapter`, `SystemClockAdapter`, `SourceDocumentCandidatesPortAdapter`,
|
||||
`PdfTextExtractionPortAdapter`, `Log4jProcessingLogger`
|
||||
- Application-Services (`DocumentProcessingCoordinator`, `AiResponseValidator`,
|
||||
`AiNamingService`) werden verdrahtet und in `DefaultBatchRunProcessingUseCase` eingebettet
|
||||
|
||||
**CLI-Adapter**
|
||||
- `BootstrapRunner` erzeugt `SchedulerBatchCommand` mit dem fertigen `BatchRunProcessingUseCase`
|
||||
|
||||
**Exit-Code-Ableitung**
|
||||
- `BatchRunOutcome` → 0 (Lauf technisch erfolgreich) oder 1 (harter Bootstrap-/Konfigurationsfehler)
|
||||
- `PdfUmbenennerApplication` ruft `System.exit(exitCode)` auf
|
||||
|
||||
**GUI-Pfad**
|
||||
- `startGuiMode()` baut via `buildGuiStartupContext()` einen `GuiStartupContext`:
|
||||
enthält `AiModelCatalogDispatcher`, `EnvironmentApiKeyResolutionAdapter`,
|
||||
`TechnicalTestOrchestrator`, `GuiConfigurationPropertiesWriter`
|
||||
- Bootstrap verdrahtet zusätzlich vier neue History-Use-Cases (`DefaultHistoryOverviewUseCase`,
|
||||
`DefaultHistoryDetailsUseCase`, `DefaultHistoryResetDocumentStatusUseCase`,
|
||||
`DefaultDeleteDocumentHistoryUseCase`) und den `DefaultPromptEditorUseCase` als anonyme
|
||||
Bridge-Implementierungen in den `GuiStartupContext`
|
||||
- `ApplicationVersionProvider.resolveVersion()` wird aufgerufen und der Wert in
|
||||
`GuiStartupContext.applicationVersion` gesetzt
|
||||
- Wenn eine Konfigurationsdatei beim Start bekannt ist, erzeugt Bootstrap zusätzlich einen
|
||||
vollständig verdrahteten `GuiPromptEditorPort` (kombiniert `FilesystemPromptPortAdapter` mit
|
||||
`DefaultPromptEditorUseCase`); ohne Konfiguration erhält der Context einen No-Op-Port
|
||||
- `GuiAdapter.start(context)` übernimmt; ab diesem Punkt liegt die Kontrolle beim GUI-Adapter
|
||||
- Im GUI-Pfad: keine SQLite-Schema-Initialisierung beim Start, kein Run-Lock-Erwerb, kein Batch-Use-Case;
|
||||
History-Operationen initialisieren die Schema-Verbindung ad-hoc pro Aufruf
|
||||
- GUI-interne Ports und deren Verbindung mit Outbound-Adaptern sind in
|
||||
`docs/architecture/gui-overview.md` beschrieben
|
||||
|
||||
---
|
||||
|
||||
## 5. Einstiegspunkte je Modul
|
||||
|
||||
### pdf-umbenenner-adapter-out
|
||||
|
||||
1. **`...ai.OpenAiHttpAdapter`** – zeigt das typische Adapter-Muster: Port-Interface implementieren,
|
||||
alle provider-spezifischen Details kapseln, `ProviderConfiguration` als einzige
|
||||
Konfigurationsquelle konsumieren. Danach `AnthropicClaudeHttpAdapter` zum Vergleich lesen.
|
||||
|
||||
2. **`...sqlite.SqliteSchemaInitializationAdapter`** – erklärt das Datenbankschema, das alle
|
||||
SQLite-Adapter voraussetzen. Hier sieht man, welche Felder in `document_record` und
|
||||
`processing_attempt` existieren und wie Schema-Evolution additiv umgesetzt ist.
|
||||
|
||||
3. **`...configuration.PropertiesConfigurationPortAdapter`** – Einstieg in die
|
||||
Konfigurationskette. Von hier aus `MultiProviderConfigurationParser` und
|
||||
`LegacyConfigurationMigrator` nachverfolgen.
|
||||
|
||||
### pdf-umbenenner-adapter-in-cli
|
||||
|
||||
1. **`...adapter.in.cli.SchedulerBatchCommand`** – komprimiertes Inbound-Adapter-Muster in einer
|
||||
einzigen Klasse. Zeigt, wie ein Inbound-Adapter ausschließlich über Port-Interfaces mit der
|
||||
Application kommuniziert.
|
||||
|
||||
2. **`package-info.java`** – beschreibt Abhängigkeitsrichtung und Verdrahtungsvertrag dieses
|
||||
Adapters.
|
||||
|
||||
3. **`SchedulerBatchCommandTest`** – zeigt, wie der Adapter ohne Bootstrap testbar ist.
|
||||
|
||||
### pdf-umbenenner-bootstrap
|
||||
|
||||
1. **`PdfUmbenennerApplication`** – Startpunkt; die kurze Kette von `main()` bis `System.exit()`
|
||||
gibt einen ersten Überblick über die gesamte Startsequenz.
|
||||
|
||||
2. **`BootstrapRunner`** – Herzstück; `buildProductionBatchUseCase()` zeigt, wie der vollständige
|
||||
Objektgraph manuell aufgebaut wird. `runHeadlessBatch()` zeigt den Headless-Kontrollfluss.
|
||||
|
||||
3. **`AiProviderSelector`** – kleinste Klasse mit größter Hebelwirkung: hier liegt die einzige
|
||||
Stelle, an der die Provider-Auswahl aus der Konfiguration auf eine konkrete
|
||||
`AiInvocationPort`-Implementierung trifft.
|
||||
|
||||
---
|
||||
|
||||
*Port-Verträge und Domain-Typen: `docs/architecture/domain-overview.md`*
|
||||
*GUI-interne Ports und GUI-Adapter-Struktur: `docs/architecture/gui-overview.md`*
|
||||
@@ -0,0 +1,193 @@
|
||||
# Architektur-Übersicht: Domain & Application
|
||||
|
||||
Dieses Dokument beschreibt die fachliche und anwendungsnahe Schicht des PDF-Umbenenners: das Modul `pdf-umbenenner-domain` und das Modul `pdf-umbenenner-application`. Es richtet sich an Entwickler, die in diesen beiden Modulen arbeiten, und soll als alleiniger Architekturkontext ausreichen – ergänzt durch die `CLAUDE.md` im Projektroot. Nicht enthalten sind Adapter-Implementierungen (Dateisystem, PDFBox, SQLite, HTTP-Clients); diese sind in `adapter-overview.md` beschrieben. GUI-spezifische Ports und deren Einbettung in den Konfigurationseditor sind in `gui-overview.md` dokumentiert.
|
||||
|
||||
---
|
||||
|
||||
## 1. Modulzweck
|
||||
|
||||
### `pdf-umbenenner-domain`
|
||||
|
||||
Enthält ausschließlich fachliche Kerntypen (Records, Enums, Sealed Interfaces) ohne jegliche Infrastrukturabhängigkeiten. Alle Typen modellieren den Problembereich und sind von anderen Modulen referenzierbar, ohne transitive Abhängigkeiten einzuschleppen.
|
||||
|
||||
### `pdf-umbenenner-application`
|
||||
|
||||
Definiert Use-Case-Orchestrierung sowie alle Inbound- und Outbound-Ports der hexagonalen Architektur. Enthält anwendungsnahe Dienste (KI-Antwort-Parsing, Pre-Check-Auswertung, Retry-Entscheidung) und Konfigurationsmodelle, aber keinerlei Infrastrukturcode (kein JDBC, kein PDFBox, kein HTTP-Client, kein JavaFX).
|
||||
|
||||
---
|
||||
|
||||
## 2. Paketstruktur
|
||||
|
||||
### `pdf-umbenenner-domain`
|
||||
|
||||
| Paket | Verantwortung |
|
||||
|-------|---------------|
|
||||
| `de.gecheckt.pdf.umbenenner.domain` | Wurzelpaket; enthält nur `package-info.java` |
|
||||
| `de.gecheckt.pdf.umbenenner.domain.model` | Alle fachlichen Kerntypen: Records, Sealed Interfaces und Enums, die die Verarbeitungsdomäne beschreiben |
|
||||
|
||||
### `pdf-umbenenner-application`
|
||||
|
||||
| Paket | Verantwortung |
|
||||
|-------|---------------|
|
||||
| `de.gecheckt.pdf.umbenenner.application` | Wurzelpaket des Application-Moduls |
|
||||
| `de.gecheckt.pdf.umbenenner.application.port.in` | Inbound-Ports (Use-Case-Interfaces) – Einstiegspunkte für den Aufrufer |
|
||||
| `de.gecheckt.pdf.umbenenner.application.port.out` | Outbound-Ports – Verträge gegenüber Infrastruktur-Adaptern (Persistenz, Dateisystem, KI, Uhr, Logging) |
|
||||
| `de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog` | Spezialisierter Outbound-Port für den Abruf verfügbarer KI-Modelle; ausschließlich im GUI-Pfad genutzt (siehe `gui-overview.md`) |
|
||||
| `de.gecheckt.pdf.umbenenner.application.port.out.history` | Outbound-Port für lesende Historien-Abfragen aus dem Historien-Tab; bewusst getrennt von den bestehenden Repositories, um diese nicht mit GUI-spezifischen Methoden aufzublähen |
|
||||
| `de.gecheckt.pdf.umbenenner.application.service` | Anwendungsnahe, zustandslose Dienste: KI-Antwort-Parsing, Pre-Check-Auswertung, Verarbeitungs-Pipeline, Retry-Entscheidung |
|
||||
| `de.gecheckt.pdf.umbenenner.application.config` | Konfigurationsmodelle der Anwendungsschicht (`RuntimeConfiguration`, Provider-Konfiguration) |
|
||||
| `de.gecheckt.pdf.umbenenner.application.config.startup` | Vollständiges Startup-Konfigurationsmodell (`StartConfiguration`) |
|
||||
| `de.gecheckt.pdf.umbenenner.application.config.provider` | Modelle für KI-Provider-Konfiguration (Provider-Familie, Einzelkonfiguration, Multi-Provider) |
|
||||
| `de.gecheckt.pdf.umbenenner.application.validation.editor` | Validierungslogik für den GUI-Konfigurationseditor (Findings, Report, API-Key-Auflösung); siehe `gui-overview.md` |
|
||||
| `de.gecheckt.pdf.umbenenner.application.validation.technicaltest` | Technischer Selbsttest: Pfad-Checks, Korrekturpläne, Checkpoints; Details in `gui-overview.md` |
|
||||
| `de.gecheckt.pdf.umbenenner.application.usecase` | Paket-Marker für Use-Case-Implementierungen |
|
||||
|
||||
---
|
||||
|
||||
## 3. Schlüsselklassen
|
||||
|
||||
### Domain-Modul
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate`**
|
||||
Record für einen PDF-Kandidaten aus dem Quellordner. Enthält keinen `Path`, sondern einen opaken `SourceDocumentLocator`, damit die Domain frei von NIO-Typen bleibt.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint`**
|
||||
Record mit einem SHA-256-Hex-String (64 Zeichen) als stabiler Dokumentidentität; Grundlage für Idempotenz und Persistenz-Lookup.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome`**
|
||||
Sealed Interface mit sechs Implementierungen, die alle möglichen Ausgänge der Dokumentverarbeitung exhaustiv abbilden:
|
||||
|
||||
| Implementierung | Bedeutung |
|
||||
|-----------------|-----------|
|
||||
| `PreCheckPassed` | Vorprüfung bestanden, KI-Pfad freigegeben |
|
||||
| `PreCheckFailed` | Deterministischer Inhaltsfehler vor KI-Aufruf |
|
||||
| `TechnicalDocumentError` | Technischer Fehler ohne erneuten KI-Aufruf |
|
||||
| `NamingProposalReady` | KI-Antwort gültig, Vorschlag liegt vor |
|
||||
| `AiTechnicalFailure` | Transienter technischer Fehler beim KI-Aufruf |
|
||||
| `AiFunctionalFailure` | Deterministischer fachlicher Fehler der KI-Antwort |
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus`**
|
||||
Enum mit acht Zuständen. Dokumentiert Zustandsübergänge und Retry-Schwellen; fachliches Herzstück der Persistenz-Semantik.
|
||||
|
||||
| Status | Bedeutung |
|
||||
|--------|-----------|
|
||||
| `READY_FOR_AI` | Verarbeitbar, KI-Pfad noch nicht durchlaufen |
|
||||
| `FAILED_RETRYABLE` | Verarbeitbar, transient fehlgeschlagen |
|
||||
| `PROPOSAL_READY` | Eingangszustand für Dateinamensbildung und Zielkopie |
|
||||
| `SUCCESS` | Terminaler Enderfolg – nur nach Zielkopie und konsistenter Persistenz |
|
||||
| `FAILED_FINAL` | Terminal, wird nicht erneut fachlich verarbeitet |
|
||||
| `SKIPPED_ALREADY_PROCESSED` | Historisierter Skip für `SUCCESS`-Dokumente |
|
||||
| `SKIPPED_FINAL_FAILURE` | Historisierter Skip für `FAILED_FINAL`-Dokumente |
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.domain.model.NamingProposal`**
|
||||
Record mit aufgelöstem Datum, `DateSource`, validiertem Titel und KI-Begründung. Führende Quelle für die Zieldateinamensbildung.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext`**
|
||||
Klasse mit Run-ID, Zeitstempel und optionalem Fingerabdruck-Filter; steuert den Umfang eines Batch-Laufs.
|
||||
|
||||
---
|
||||
|
||||
### Application-Modul
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration`**
|
||||
Schmales Laufzeit-Record (`maxPages`, `maxRetriesTransient`, `aiContentSensitivity`). Wird von den Use Cases verwendet, enthält keine Pfade.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration`**
|
||||
Vollständige typisierte Startup-Konfiguration; einziger Ort in der Anwendungsschicht, an dem `java.nio.file.Path` vorkommt. Wird vom `ConfigurationPort` geliefert und von Bootstrap ausgewertet.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingService`**
|
||||
Statische Hilfsklasse: überführt ein Extraktionsergebnis über den Pre-Check in ein `DocumentProcessingOutcome`. Kompakte Pipeline-Klasse; guter Einstieg zum Verständnis der Verarbeitungslogik.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.application.service.AiResponseParser`**
|
||||
Statischer Parser für KI-Antworten in `ParsedAiResponse`. Erzwingt reines JSON-Objekt; Validierungslogik liegt vollständig in der Anwendungsschicht.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt`**
|
||||
Record für einen Versuchshistorie-Eintrag; enthält u. a. Provider-Identifikator, Modellname, Prompt-Identifikator, aufgelöstes Datum und finalen Zieldateinamen.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord`**
|
||||
Record für den Dokument-Stammsatz; enthält Gesamtstatus, Fehler- und Transientzähler sowie letzten Zielpfad.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery`**
|
||||
Record mit den Abfrageparametern für den Historien-Tab: optionaler Suchbegriff (`searchText`, Teilstring, case-insensitiv), optionaler Status-Filter (`statusFilter` als Enum-Name) und Limit der zurückzugebenden Zeilen (Standard `DEFAULT_LIMIT = 501`). Das Limit 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr als 500 Treffer vorhanden sind.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow`**
|
||||
Einzelzeile der Dokumentenliste im Historien-Tab. Felder: `fingerprint`, `overallStatus`, `sourceFileName`, `targetFileName` (null wenn noch kein Erfolg), `sourcePath`, `updatedAt` und `attemptCount`. Stammt aus `document_record` mit einem `COUNT`-Ausdruck über `processing_attempt`.
|
||||
|
||||
**`de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult`**
|
||||
Versiegeltes Ergebnis-Interface für `PromptPort.savePrompt(String)`. Zulässige Ausprägungen: `Saved` (Erfolg, enthält absoluten Pfad), `WriteFailed` (I/O-Fehler beim Schreiben der Temp-Datei), `TargetDirectoryMissing` (Zielordner fehlt), `AtomicMoveFailed` (atomares Verschieben nicht möglich; kein stiller Fallback).
|
||||
|
||||
**Neue Use-Case-Implementierungen im Paket `de.gecheckt.pdf.umbenenner.application.usecase`**
|
||||
|
||||
| Klasse | Zweck |
|
||||
|--------|-------|
|
||||
| `DefaultHistoryOverviewUseCase` | Lädt die gefilterte Dokumentenübersicht über `HistoryQueryPort.loadOverview`; gibt `HistoryOverviewResult` mit Liste und `hasMore`-Flag zurück |
|
||||
| `DefaultHistoryDetailsUseCase` | Lädt Stammsatz und alle Verarbeitungsversuche für einen Fingerprint über `HistoryQueryPort`; gibt `HistoryDetailsResult` zurück |
|
||||
| `DefaultHistoryResetDocumentStatusUseCase` | Feldgenauer Status-Reset via `UnitOfWorkPort.TransactionOperations.resetDocumentStatusForRetry`; setzt `overall_status`, `content_error_count`, `transient_error_count` und `last_failure_instant` zurück; lässt die Versuchshistorie unangetastet |
|
||||
| `DefaultDeleteDocumentHistoryUseCase` | Löscht Stammsatz und alle Verarbeitungsversuche vollständig und transaktional via `UnitOfWorkPort` |
|
||||
| `DefaultPromptEditorUseCase` | Delegiert Laden, Speichern und Standard-Anlegen der Prompt-Datei an `PromptPort` und `ResourceCreationPort`; wird im GUI-Pfad über `GuiPromptEditorPort` angesteuert |
|
||||
|
||||
---
|
||||
|
||||
## 4. Inbound Ports
|
||||
|
||||
### `BatchRunProcessingUseCase`
|
||||
|
||||
```
|
||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase
|
||||
```
|
||||
|
||||
Zentraler Use-Case-Einstiegspunkt für den gesamten Batch-Betrieb. Beschreibt den Anwendungszweck in einer einzigen Methode:
|
||||
|
||||
```java
|
||||
BatchRunOutcome execute(BatchRunContext context);
|
||||
```
|
||||
|
||||
Mögliche Ergebnisse:
|
||||
|
||||
| Ergebnis | Bedeutung |
|
||||
|----------|-----------|
|
||||
| `SUCCESS` | Lauf technisch ordnungsgemäß abgeschlossen |
|
||||
| `LOCK_UNAVAILABLE` | Run-Lock konnte nicht erworben werden |
|
||||
| `FAILURE` | Harter technischer Fehler beim Laufstart |
|
||||
|
||||
---
|
||||
|
||||
## 5. Outbound Ports
|
||||
|
||||
Alle Outbound-Ports liegen in `de.gecheckt.pdf.umbenenner.application.port.out` (bzw. dessen Unterpaket `modelcatalog`). Implementierungen befinden sich ausschließlich in `pdf-umbenenner-adapter-out`; Details dort sind in `adapter-overview.md` beschrieben.
|
||||
|
||||
| Interface | Zweck | Hauptmethode(n) |
|
||||
|-----------|-------|-----------------|
|
||||
| `SourceDocumentCandidatesPort` | Scannt Quellordner, liefert Kandidaten in deterministischer Reihenfolge | `List<SourceDocumentCandidate> loadCandidates()` |
|
||||
| `FingerprintPort` | Berechnet SHA-256-Fingerabdruck eines Kandidaten | `FingerprintResult computeFingerprint(SourceDocumentCandidate)` |
|
||||
| `PdfTextExtractionPort` | Extrahiert Text und Seitenanzahl aus einer PDF | `PdfExtractionResult extractTextAndPageCount(...)` |
|
||||
| `AiInvocationPort` | Ruft den aktiven KI-Dienst auf; provider-neutral | `AiInvocationResult invoke(AiRequestRepresentation)` |
|
||||
| `PromptPort` | Lädt das Prompt-Template aus der konfigurierten Quelle; speichert geänderten Inhalt atomar via `savePrompt(String)` – der Pfad stammt aus der Adapter-internen Konfiguration, nicht aus dem Port-Aufruf | `PromptLoadingResult loadPrompt()`, `PromptSaveResult savePrompt(String content)` |
|
||||
| `TargetFileCopyPort` | Kopiert Quelldokument unter aufgelöstem Namen in den Zielordner (Temp + Rename) | `TargetFileCopyResult copyToTarget(...)` |
|
||||
| `TargetFileRenamePort` | Atomare Umbenennung einer bereits kopierten Zieldatei (manuelle Korrektur) | `TargetFileRenameResult rename(...)` |
|
||||
| `RunLockPort` | Exklusiver Lauf-Lock gegen parallele Instanzen | `acquire()` / `release()` |
|
||||
| `PersistenceSchemaInitializationPort` | Idempotente Schema-Initialisierung der SQLite-Datenbank | `initializeSchema()` |
|
||||
| `ClockPort` | Abstraktion des Systemtakts | `Instant now()` |
|
||||
| `ConfigurationPort` | Lädt die typisierte Startup-Konfiguration | `StartConfiguration loadConfiguration()` |
|
||||
| `ProcessingLogger` | Logging-Delegation; sensibles KI-Content-Logging über Flag gesteuert | `info/debug/warn/error/debugSensitiveAiContent(...)` |
|
||||
| `AiModelCatalogPort` | Abruf verfügbarer Modelle vom Provider (nur GUI-Pfad, siehe `gui-overview.md`) | `ModelCatalogResult fetchAvailableModels(...)` |
|
||||
| `PathCheckPort` | Lesende Pfad-Prüfung für den technischen Selbsttest | `isDirectoryReadable`, `isDirectoryWritableOrCreatable`, `isFileReadable`, `isSqlitePathUsable` |
|
||||
| `ResourceCreationPort` | Schreibende Korrektur-Aktionen (Ordner anlegen, Prompt-Datei erzeugen, SQLite-Pfad vorbereiten) | `createDirectory`, `createPromptFile`, `prepareSqlitePath` |
|
||||
| `ApiKeyResolutionPort` | Ermittelt API-Key-Herkunft pro Provider-Familie für die GUI-Validierung | `EffectiveApiKeyDescriptor resolve(...)` |
|
||||
| `HistoryQueryPort` | Lesender Zugriff auf die Verarbeitungshistorie für den Historien-Tab; bewusst getrennt von den regulären Repositories | `List<DocumentHistoryRow> loadOverview(HistoryQuery)`, `Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint)`, `List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint)` |
|
||||
|
||||
> **Hinweis zu GUI-spezifischen Ports:** `AiModelCatalogPort`, `PathCheckPort`, `ResourceCreationPort`, `ApiKeyResolutionPort` und `HistoryQueryPort` werden ausschließlich im GUI-Pfad genutzt. Ihre Implementierungen und der Aufrufkontext sind in `gui-overview.md` bzw. `adapter-overview.md` beschrieben.
|
||||
|
||||
> **Hinweis zu `UnitOfWorkPort.TransactionOperations`:** Die innere Schnittstelle `TransactionOperations` wurde um die Methode `resetDocumentStatusForRetry(DocumentFingerprint)` erweitert. Diese setzt feldgenau `overall_status → READY_FOR_AI`, `content_error_count → 0`, `transient_error_count → 0` und `last_failure_instant → NULL`, ohne die Versuchshistorie zu berühren. Die Implementierung liegt in `SqliteUnitOfWorkAdapter`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Einstiegspunkte für neue Entwickler
|
||||
|
||||
Die folgende Lesereihenfolge gibt den kürzesten Weg zum Gesamtverständnis:
|
||||
|
||||
1. **`de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase`** – beschreibt den gesamten Anwendungszweck in einer Methode.
|
||||
2. **`de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus`** – fachliches Herzstück; dokumentiert Zustandsübergänge und Retry-Schwellen.
|
||||
3. **`de.gecheckt.pdf.umbenenner.domain.model`** (gesamtes Paket) – gemeinsame Sprache aller Schichten; vollständig in wenigen Minuten lesbar.
|
||||
4. **`de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingService`** – kompakte Pipeline-Klasse; zeigt, wie Pre-Check und Ergebnis-Typen zusammenspielen.
|
||||
5. **`de.gecheckt.pdf.umbenenner.application.port.out`** (gesamtes Paket) – vollständige Außengrenzen der Architektur; jeder Infrastrukturzugriff ist hier als Port definiert.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Architektur-Übersicht: GUI (adapter-in-gui)
|
||||
|
||||
Diese Datei beschreibt den Inbound-Adapter `pdf-umbenenner-adapter-in-gui` – die JavaFX-Desktop-GUI des PDF-Umbenenners. Sie ist zusammen mit `CLAUDE.md` im Projektroot als alleiniger Architekturkontext für GUI-Arbeit gedacht. Domain-Typen, Application-Ports und Outbound-Adapter (Dateisystem, SQLite, KI-HTTP) sind hier bewusst nicht beschrieben; dafür gelten `docs/architecture/domain-overview.md` und `docs/architecture/adapter-overview.md`. **Das JavaFX-Threading-Modell (Abschnitt 4) ist verbindlich und muss strikt eingehalten werden – GUI-Entwickler sollten diesen Abschnitt als erstes lesen.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Modulzweck
|
||||
|
||||
`pdf-umbenenner-adapter-in-gui` ist der Inbound-Adapter für die Desktop-Oberfläche. Er:
|
||||
|
||||
- empfängt den Startaufruf von der Bootstrap-Schicht über `GuiAdapter`,
|
||||
- baut das JavaFX-Hauptfenster auf,
|
||||
- delegiert alle fachlichen und technischen Operationen an Bootstrap-seitig verdrahtete Ports,
|
||||
- zeigt Ergebnisse ausschließlich auf dem JavaFX Application Thread an.
|
||||
|
||||
Das Modul enthält **keine fachliche Logik**, keinen Datenbankzugriff, keinen HTTP-Code und keine PDF-Verarbeitung. Es koordiniert lediglich Benutzereingaben, Worker-Threads und JavaFX-Controls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Paketstruktur
|
||||
|
||||
```
|
||||
de.gecheckt.pdf.umbenenner.adapter.in.gui
|
||||
│
|
||||
├── (root) Einstiegspunkt, Hauptfenster, Orchestrierung, GUI-interne
|
||||
│ Ports, Hilfsklassen für Fenstertitel, System-Tray,
|
||||
│ Dateiladen/-schreiben und Startkontext.
|
||||
│ Enthält außerdem: GuiStatusBar, GuiPromptEditorTab und
|
||||
│ GuiPromptEditorPort.
|
||||
│
|
||||
├── batchrun Komponenten für den Tab „Verarbeitungslauf":
|
||||
│ Worker-Koordinator, Tab-Ansicht, Ergebniszeilen,
|
||||
│ PDF-Vorschau, Dateiname-Editor sowie GUI-interne
|
||||
│ Port-Interfaces für Batch-Run, Mini-Run, manuelles
|
||||
│ Umbenennen/Kopieren, Status-Reset und historischen Kontext.
|
||||
│ Enthält außerdem: BatchRunSummaryBanner und
|
||||
│ ProcessingStatusPresentation.
|
||||
│
|
||||
├── editor View-Modell- und Zustandstypen ohne JavaFX-Controls
|
||||
│ (Ausnahme: GuiModelFieldContainer). Enthält Snapshot,
|
||||
│ Baseline/Current-Values, Dirty-State-Berechnung,
|
||||
│ Provider-Konfigurationszustände, API-Key-Zustände,
|
||||
│ Validierungsergebnisse, Meldungs- und Feldbefund-Typen.
|
||||
│
|
||||
└── history Komponenten für den Tab „Verlauf": Tab-Ansicht mit
|
||||
zweigeteiltem Layout (Liste + Detail), Filter, Aktionen
|
||||
(Status-Reset / vollständiges Löschen) sowie die vier
|
||||
Bridge-Interfaces GuiHistoryOverviewPort,
|
||||
GuiHistoryDetailsPort, GuiHistoryResetDocumentStatusPort
|
||||
und GuiDeleteDocumentHistoryPort.
|
||||
```
|
||||
|
||||
**Tab-Reihenfolge:** `Konfiguration | Verarbeitungslauf | Verlauf | Prompt`
|
||||
|
||||
---
|
||||
|
||||
## 3. Schlüsselklassen
|
||||
|
||||
### Root-Paket
|
||||
|
||||
| Klasse (Kurzname) | Rolle |
|
||||
|---|---|
|
||||
| `GuiAdapter` | Einziger öffentlicher Bootstrap-Einstiegspunkt. Speichert `GuiStartupContext` im `GuiStartupContextHolder` und startet JavaFX via `Application.launch`. Genau einmal pro JVM aufrufbar. |
|
||||
| `PdfUmbenennerGuiApplication` | JavaFX-`Application`-Unterklasse. Baut in `start(Stage)` Hauptfenster, `GuiConfigurationEditorWorkspace`, Titelaktualisierungs-Listener, Close-Handler und System-Tray auf. Triggert nach Anzeige `autoLoadLastConfiguration()`. |
|
||||
| `GuiStartupContext` | Immutable Record mit allen Bootstrap-gelieferten Ports und Services: Dateilader/-schreiber, Modellkatalog-Port, API-Key-Resolution-Port, Technical-Test-Orchestrator, Correction-Execution-Service, Batch-Run-Launcher, Mini-Run-Launcher, Reset-Port, Manual-Rename-Port, Manual-Copy-Port, Historical-Context-Port, `applicationVersion` (Versions-String aus `ApplicationVersionProvider`), `promptEditorPort`, `historyOverviewPort`, `historyDetailsPort`, `historyResetDocumentStatusPort`, `deleteDocumentHistoryPort`. Bietet `blank()`-Fabrikmethode für Tests. |
|
||||
| `GuiConfigurationEditorWorkspace` | Herzstück der Oberfläche. Baut `TabPane` mit den vier Tabs (Konfiguration, Verarbeitungslauf, Verlauf, Prompt), verwaltet `GuiConfigurationEditorState`, koordiniert Lade- und Schreibvorgänge auf Worker-Threads, steuert Dirty-State-Anzeige und Fenstertitel. |
|
||||
| `GuiStatusBar` | Permanente Statuszeile am unteren Rand des Hauptfensters. Drei Segmente: links Anwendungsversion im Format `V<version>` (z. B. `Vdev`), Mitte aktiver Provider und Modell aus geladener Konfiguration, rechts Pfad der geladenen Konfigurationsdatei. Ohne geladene Konfiguration zeigen Mitte und Rechts den Platzhaltertext. |
|
||||
| `GuiPromptEditorTab` | Tab „Prompt" mit `TextArea` zum Lesen, Bearbeiten und Speichern der Prompt-Datei. Dirty-State markiert den Tab-Titel mit einem Asterisk. Speichert atomar via `GuiPromptEditorPort`. Bietet „Auf Standard zurücksetzen" (füllt `TextArea` ohne zu speichern) und „Standard-Prompt erstellen" bei fehlender Datei. |
|
||||
| `GuiModelCatalogCoordinator` | Asynchroner Modellabruf über `AiModelCatalogPort` auf Daemon-Thread `gui-model-catalog`. Liefert Ergebnis via `Platform.runLater` und aktualisiert ComboBox oder manuelles TextField. |
|
||||
| `GuiTechnicalTestCoordinator` | Führt `TechnicalTestOrchestrator` asynchron auf Daemon-Thread `gui-technical-test` aus, ohne implizites Speichern. Replace-Semantik in `pendingMessages`. |
|
||||
| `GuiCorrectionDialogCoordinator` | Empfängt `TechnicalTestReport`, leitet `CorrectionPlan` ab, zeigt gesammelten Bestätigungsdialog. Kein stiller Schreibzugriff. |
|
||||
| `GuiUnsavedChangesGuard` | Drei-Wege-Schutzdialog (Speichern / Verwerfen / Abbrechen) vor Neu, Öffnen und Schließen. Dialog-Supplier ist injizierbar für Tests ohne Scene. |
|
||||
| `SystemTrayManager` | Verwaltet Windows-System-Tray-Icon. Überbrückt AWT-EDT nach JavaFX via `Platform.runLater` für Stage-Operationen. |
|
||||
|
||||
### Paket `editor`
|
||||
|
||||
| Klasse (Kurzname) | Rolle |
|
||||
|---|---|
|
||||
| `GuiConfigurationEditorState` | Record mit `loadedFileSnapshot`, `baselineValues`, `values`, `pendingMigrationMessage`. Dirty-State wird per Vergleich berechnet, kein Flag. |
|
||||
| `GuiConfigurationValues` | Hält alle editierbaren Konfigurationsfelder als JavaFX-freie Plain-Java-Typen. |
|
||||
|
||||
### Paket `batchrun`
|
||||
|
||||
| Klasse (Kurzname) | Rolle |
|
||||
|---|---|
|
||||
| `GuiBatchRunCoordinator` | Besitzt den Worker-Thread für Batch- und Mini-Run. Übersetzt `BatchRunProgressObserver`-Callbacks via `Platform.runLater`. Soft-Stop per `AtomicBoolean`. Enthält `toRow()` inkl. historischem Kontext-Nachladen. |
|
||||
| `GuiBatchRunTab` | Zweiter Haupt-Tab mit `TableView`, `ProgressBar`, Start-/Stop-Buttons und Detailbereich. Implementiert `GuiBatchRunCoordinator.Listener`. |
|
||||
| `BatchRunSummaryBanner` | Einzeilige Zusammenfassungsleiste nach Laufabschluss, unterhalb des Fortschrittsbalkens. Zeigt nur Kategorien mit Zähler > 0. Farbe ist niemals das einzige Unterscheidungsmerkmal: jedes Segment enthält Icon und Text. |
|
||||
| `ProcessingStatusPresentation` | Zentrale Mapping-Klasse für Status-Icons, CSS-Farben, Tooltip-Texte und Summary-Kategoriebeschriften aller `DocumentCompletionStatus`-Werte. Einzige autoritative Quelle für alle Anzeigeorte (Tabelle, Detail, Banner). Enthält keine JavaFX-Typen; zustandslos und statisch. Farbe ist niemals das einzige Unterscheidungsmerkmal. |
|
||||
| `PdfPreviewPane` | Asynchrones PDF-Rendering via PDFBox auf Single-Thread-Executor `pdf-preview-worker`. Stale-Request-Schutz via `AtomicLong`-Sequenznummer, In-Memory-Seiten-Cache. |
|
||||
| `FileNameEditorPane` | Editor für den Zieldateinamen. Drei-Zustands-Modell: KI-Vorschlag / letzter gespeicherter / aktuelle Eingabe. Clientseitige Validierung; Speicher-Aufruf delegiert an Tab. |
|
||||
| `AiFailureMessageTranslator` | Übersetzt englische technische Fehlermeldungen in deutsche Benutzertexte. Paket-privat, zustandslos. |
|
||||
|
||||
### Paket `history`
|
||||
|
||||
| Klasse (Kurzname) | Rolle |
|
||||
|---|---|
|
||||
| `GuiHistoryTab` | Tab „Verlauf" mit zweigeteiltem Layout: links Dokumentenliste mit Freitext- und Statusfilter, rechts Detailbereich mit Stammsatz und Versuchshistorie. Aktionen: Status-Reset (feldgenau, Versuchshistorie bleibt) und vollständiges Löschen (mit Bestätigungsdialog). Alle Datenbankoperationen auf Worker-Thread, UI-Updates via `Platform.runLater`. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Threading-Modell
|
||||
|
||||
Das Modell ist verbindlich. Jede Verletzung dieser Regeln führt zu sporadischen `IllegalStateException`-Fehlern oder einer eingefrorenen Oberfläche.
|
||||
|
||||
### 4.1 Worker-Threads
|
||||
|
||||
Alle blockierenden Operationen laufen auf benannten Daemon-Threads außerhalb des JavaFX Application Thread.
|
||||
|
||||
| Thread-Name | Koordinator-Klasse | Operationen |
|
||||
|---|---|---|
|
||||
| `gui-batch-run` | `GuiBatchRunCoordinator` | Batch-Launcher, Mini-Run-Launcher, Reset-Port, historischer Kontext |
|
||||
| `gui-model-catalog` | `GuiModelCatalogCoordinator` | `modelCatalogPort.fetchAvailableModels(...)` |
|
||||
| `gui-technical-test` | `GuiTechnicalTestCoordinator` | `orchestrator.runTests(...)` |
|
||||
| Korrektur-Worker (anonym) | `GuiCorrectionDialogCoordinator` | `correctionExecutionService.execute(...)` |
|
||||
| `pdf-preview-worker` | `PdfPreviewPane` | `PDDocument` laden, `PDFRenderer.renderImageWithDPI`, `PDDocument.close` |
|
||||
| Dateisystem-Worker (inline) | `GuiConfigurationEditorWorkspace` | `configurationFileLoader.load(...)`, `configurationFileWriter.write(...)` |
|
||||
| Inline-Worker (anonym) | `GuiHistoryTab` | `historyOverviewPort.loadOverview(...)`, `historyDetailsPort.loadDetails(...)`, `historyResetDocumentStatusPort.resetStatus(...)`, `deleteDocumentHistoryPort.deleteHistory(...)` |
|
||||
| Inline-Worker (anonym) | `GuiPromptEditorTab` | `promptEditorPort.loadCurrentPrompt()`, `promptEditorPort.save(...)`, `promptEditorPort.createDefaultPromptIfMissing(...)` |
|
||||
|
||||
### 4.2 JavaFX Application Thread
|
||||
|
||||
Alle Mutationen an JavaFX-Controls und alle Dialoganzeigen ausschließlich auf dem JavaFX Application Thread. Kein direktes Schreiben auf Controls vom Worker-Thread.
|
||||
|
||||
### 4.3 Übergangsmechanismus Worker → FX
|
||||
|
||||
Der Übergang erfolgt grundsätzlich via:
|
||||
|
||||
```java
|
||||
Platform.runLater(runnable);
|
||||
```
|
||||
|
||||
Es werden **keine** `javafx.concurrent.Task` und kein `Service` verwendet. Die Koordinatoren steuern Threading manuell über zwei injizierbare Strategien:
|
||||
|
||||
| Injektionspunkt | Typ | Produktion | Tests |
|
||||
|---|---|---|---|
|
||||
| `threadFactory` | `Function<Runnable, Thread>` | `Thread::new` (Daemon) | synchroner Direktaufruf |
|
||||
| `fxDispatcher` | `Consumer<Runnable>` | `Platform::runLater` | synchroner Direktaufruf |
|
||||
|
||||
Durch diese Injektion sind Unit-Tests vollständig ohne JavaFX-Runtime möglich.
|
||||
|
||||
### 4.4 Stale-Request-Schutz
|
||||
|
||||
`PdfPreviewPane` vergibt für jede Renderanfrage eine inkrementelle `AtomicLong`-Sequenznummer. Ein abgeschlossenes Render-Ergebnis wird nur dann auf der UI angezeigt, wenn seine Sequenznummer noch der aktuellen entspricht. Veraltete Ergebnisse werden still verworfen.
|
||||
|
||||
---
|
||||
|
||||
## 5. GUI-interne Ports
|
||||
|
||||
> **Abgrenzung:** Die folgenden Interfaces sind **keine hexagonalen Outbound-Ports der Application-Schicht**. Sie sind modul-interne Brücken, über die `GuiAdapter` die Bootstrap-seitig verdrahteten Implementierungen in die GUI-Klassen einschleust. Die eigentlichen Application-Ports (`AiInvocationPort`, `AiModelCatalogPort` usw.) und deren Outbound-Adapter-Implementierungen sind in `docs/architecture/domain-overview.md` und `docs/architecture/adapter-overview.md` beschrieben.
|
||||
|
||||
### Root-Paket
|
||||
|
||||
| Interface | Zweck |
|
||||
|---|---|
|
||||
| `GuiConfigurationFileLoader` | Lädt eine `.properties`-Datei und liefert einen `GuiConfigurationEditorState`. Abstrahiert Migration und Bootstrap-Verdrahtung vom GUI-Code. |
|
||||
| `GuiConfigurationFileWriter` | Schreibt aktuelle `GuiConfigurationValues` als normalisierte `.properties` inkl. Backup-Schema. |
|
||||
|
||||
### Paket `batchrun`
|
||||
|
||||
| Interface | Zweck |
|
||||
|---|---|
|
||||
| `GuiBatchRunLauncher` | Bootstrap-Brücke für den regulären Batch-Run auf dem Worker-Thread. |
|
||||
| `GuiMiniRunLauncher` | Bootstrap-Brücke für einen auf einen Fingerprint-Filter beschränkten Mini-Run. |
|
||||
| `GuiResetDocumentStatusPort` | Bootstrap-Brücke für den vollständigen Persistenz-Reset (Stammsatz und Versuchshistorie werden gelöscht) ohne Folge-Run. |
|
||||
| `GuiManualFileRenamePort` | Bootstrap-Brücke für die manuelle Umbenennung der Zieldatei (Worker-Thread). |
|
||||
| `GuiManualFileCopyPort` | Bootstrap-Brücke für die Kopie mit benutzerdefiniertem Zieldateinamen bei FAILED/SKIPPED-Dokumenten (Worker-Thread). |
|
||||
| `GuiHistoricalDocumentContextPort` | Nachladen des vollständigen historischen Verarbeitungskontexts für übersprungene Dokumente (Worker-Thread). |
|
||||
| `GuiHistoricalFileNamePort` | Spezialisierter Port für den letzten bekannten KI-Dateinamen. Weitgehend durch `GuiHistoricalDocumentContextPort` abgelöst, aber noch im Einsatz. |
|
||||
|
||||
### Root-Paket (GUI-interne Ports)
|
||||
|
||||
| Interface | Zweck |
|
||||
|---|---|
|
||||
| `GuiPromptEditorPort` | Bootstrap-Brücke zum Prompt-Editor-Use-Case: Laden (`loadCurrentPrompt()`), atomares Speichern (`save(String)`) und Standard-Anlegen (`createDefaultPromptIfMissing(...)`) der Prompt-Datei. |
|
||||
|
||||
### Paket `history`
|
||||
|
||||
| Interface | Zweck |
|
||||
|---|---|
|
||||
| `GuiHistoryOverviewPort` | Bootstrap-Brücke zur Historien-Übersicht; lädt gefilterte Dokumentenliste via `loadOverview(Path configFilePath, HistoryQuery)`. `configFilePath` ermöglicht der Bootstrap-Implementierung, die SQLite-Datenbank aus der aktuell geladenen Konfiguration abzuleiten. |
|
||||
| `GuiHistoryDetailsPort` | Bootstrap-Brücke zur Detailansicht; lädt Stammsatz und alle Verarbeitungsversuche für einen Fingerprint via `loadDetails(Path, DocumentFingerprint)`. |
|
||||
| `GuiHistoryResetDocumentStatusPort` | Bootstrap-Brücke für den feldgenauen Status-Reset im Historien-Tab (`overall_status → READY_FOR_AI`, Fehlerzähler → 0, `last_failure_instant → null`). Die Versuchshistorie bleibt vollständig erhalten. **Abgrenzung:** `GuiResetDocumentStatusPort` im `batchrun`-Paket löscht dagegen Stammsatz und Versuchshistorie vollständig. |
|
||||
| `GuiDeleteDocumentHistoryPort` | Bootstrap-Brücke zum vollständigen Löschen von Stammsatz und Versuchshistorie via `deleteHistory(Path, DocumentFingerprint)`; destruktiv und nicht rückgängig zu machen. Die GUI zeigt vor dem Aufruf einen Bestätigungsdialog. |
|
||||
|
||||
Alle Implementierungen dieser Interfaces liegen in `pdf-umbenenner-bootstrap`. Das GUI-Modul kennt ausschließlich die Interface-Typen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Einstiegspunkte für neue Entwickler
|
||||
|
||||
Folgende Klassen und Dateien decken den schnellsten Einstieg ab:
|
||||
|
||||
1. **`GuiAdapter`** – Architekturgrenze zur Bootstrap-Schicht in zwei Methoden. Zeigt, wie die GUI aus Bootstrap-Sicht aufgerufen wird.
|
||||
|
||||
2. **`GuiStartupContext`** – Vollständige Liste aller Ports und Services, die Bootstrap in die GUI injiziert. Wer wissen will, was die GUI von außen bekommt, liest diesen Record.
|
||||
|
||||
3. **`GuiConfigurationEditorWorkspace`** – Zentrale UI-Klasse: Tab-Aufbau, Sektionen, Editor-Zustand, Dirty-State, Datei-I/O, Sub-Koordinatoren. Einstieg für alle Arbeiten am Konfigurationseditor-Tab.
|
||||
|
||||
4. **`GuiConfigurationEditorState` / `GuiConfigurationValues`** – View-Modell ohne JavaFX-Controls. Einstieg für alle Änderungen an editierbaren Konfigurationsfeldern und Dirty-State-Logik.
|
||||
|
||||
5. **`GuiBatchRunCoordinator`** – Threading-Modell in seiner reinsten Form: Worker-Thread, `Platform.runLater`-Übergabe, Soft-Stop, Listener-Protokoll. Einstieg für alle Arbeiten am Verarbeitungslauf-Tab.
|
||||
|
||||
6. **`batchrun/package-info.java`** – Kompakte Beschreibung des Threading-Kontrakts, der Abbruch-Semantik und der Konfigurationsquelle für dieses Paket.
|
||||
|
||||
### Querverweise
|
||||
|
||||
- Application-Ports und Domain-Typen (`NamingProposal`, `ProcessingStatus`, `DocumentFingerprint` usw.): `docs/architecture/domain-overview.md`
|
||||
- Outbound-Adapter-Implementierungen (Dateisystem, SQLite, KI-HTTP, PDFBox) und Bootstrap-Verdrahtung: `docs/architecture/adapter-overview.md`
|
||||
+8
-3
@@ -236,7 +236,7 @@ enthält JavaFX (Win-Classifier), alle Module, PDFBox, SQLite-JDBC und Log4j2.
|
||||
| 15 | Legacy-Migration mit `.bak`-Sicherung | **erfüllt** | `LegacyConfigurationMigrator` in `adapter-out`; GUI-Pfad ruft `detectedLegacyConfiguration` + `migrateConfigurationIfNeeded` in `BootstrapRunner` auf. `GuiConfigurationPropertiesWriterTest` prüft Backup-Schema. |
|
||||
| 16 | Keine neuen Provider über Claude/OpenAI-kompatibel hinaus | **erfüllt** | Codebase enthält ausschließlich `ClaudeAiInvocationAdapter` und `OpenAiCompatibleAiInvocationAdapter`. Kein dritter Provider. |
|
||||
| 17 | Keine neuen Distributionsformate (EXE/Installer) | **erfüllt** | `pom.xml` des Bootstrap-Moduls nutzt ausschließlich `maven-shade-plugin`. Kein `launch4j`, kein `jpackage`, kein Installer. |
|
||||
| 18 | Kein manueller Verarbeitungslauf aus GUI | **erfüllt** | `adapter-in-gui` enthält keine Klasse, die `BatchRunProcessingUseCase` aus einem GUI-Event aufruft. Kein „Start"-Button, keine Batch-Ausführungslogik im GUI-Adapter. |
|
||||
| 18 | Kein manueller Verarbeitungslauf aus GUI (abgelöst ab V2.1) | **erfüllt** | `adapter-in-gui` enthält keine Klasse, die `BatchRunProcessingUseCase` aus einem GUI-Event aufruft. Kein „Start"-Button, keine Batch-Ausführungslogik im GUI-Adapter. |
|
||||
| 19 | Keine DB-/Historienanzeige | **erfüllt** | Kein SQLite-Lesepfad aus `adapter-in-gui`. Kein Historien-Tab. Kein Ergebnis-Browser. |
|
||||
| 20 | Keine fachlichen Änderungen an Kernverarbeitung | **erfüllt** | `DefaultBatchRunProcessingUseCase`, `DocumentProcessingCoordinator`, `AiNamingService`, `AiResponseValidator` sind gegenüber dem V1.1-Freigabestand unverändert. E2E-Tests (`BatchRunEndToEndTest`, 11 Szenarien) sind alle grün. |
|
||||
|
||||
@@ -293,10 +293,10 @@ GUI-Teststrategie (kein TestFX über Monocle hinaus) und ist keine Abweichung vo
|
||||
Die folgenden Themen wurden im V2.0-Umfang nachweislich **nicht** implementiert und sind
|
||||
ausdrücklich für spätere Ausbaustufen vorgesehen:
|
||||
|
||||
- **Manueller Verarbeitungslauf aus der GUI** (V2.1+)
|
||||
- **Manueller Verarbeitungslauf aus der GUI** (V2.1+) – **umgesetzt ab V2.1** (Tab „Verarbeitungslauf")
|
||||
- **DB-/Historienansicht** in der GUI (V2.x+)
|
||||
- **Kosten-Tracking** und Token-/Preisberechnung (V2.x+)
|
||||
- **EXE-Wrapper / Installer** (V3+)
|
||||
- **EXE-Wrapper / Installer** (V3+) – **umgesetzt ab V3**: EXE-Wrapper (M14), MSI-Installer (M15)
|
||||
- **Weitere KI-Provider** über Claude und OpenAI-kompatibel hinaus (V3+)
|
||||
- **Automatischer Fallback zwischen Providern** (V3+)
|
||||
- **Profilverwaltung mit mehreren Konfigurationen je Provider** (V3+)
|
||||
@@ -348,3 +348,8 @@ das Fenster beim Start automatisch maximiert wird.
|
||||
Konfigurationsdatei in `java.util.prefs.Preferences` (Schlüssel `lastConfigPath`).
|
||||
Beim nächsten Start wird diese Datei automatisch geladen, sofern sie noch existiert.
|
||||
Fehlt die Datei, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
||||
|
||||
---
|
||||
|
||||
> **Hinweis:** Für die Ausbaustufen V2.1 bis V2.9 wurden keine separaten Befundlisteneinträge
|
||||
> erstellt. Befunde, Fixes und Verbesserungen dieser Stufen sind in den Gitea-Issues dokumentiert.
|
||||
|
||||
+213
-5
@@ -63,7 +63,7 @@ mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
||||
|
||||
### Umfang der GUI
|
||||
|
||||
Die GUI enthält zwei Tabs:
|
||||
Die GUI enthält fünf Tabs:
|
||||
|
||||
- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für
|
||||
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
|
||||
@@ -75,12 +75,58 @@ Die GUI enthält zwei Tabs:
|
||||
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
|
||||
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
|
||||
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
|
||||
- **Tab „Scheduler"** – Optionaler automatischer Scheduler für periodische Verarbeitungsläufe.
|
||||
Kann gestartet, gestoppt und mit einem konfigurierten Intervall betrieben werden. Während
|
||||
der Scheduler aktiv ist, sind Tab 1 „Konfiguration" und der manuelle Lauf gesperrt.
|
||||
Erfordert `scheduler.enabled=true` und ein gültiges `scheduler.interval.seconds` in der
|
||||
gespeicherten Konfiguration.
|
||||
- **Tab „Verlauf"** – Ansicht aller bisher verarbeiteten Dokumente mit Status, Dateinamen
|
||||
und Verarbeitungsdetails direkt aus der SQLite-Datenbank. Ermöglicht Status-Reset und
|
||||
Löschung einzelner Einträge.
|
||||
- **Tab „Prompt"** – Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt
|
||||
aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel).
|
||||
Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`).
|
||||
Ein „Auf Standard zurücksetzen"-Button befüllt die TextArea mit der Standard-Vorlage,
|
||||
ohne zu speichern. Fehlt die Prompt-Datei am konfigurierten Pfad, wird ein
|
||||
„Standard-Prompt erstellen"-Button angezeigt. Der Tab wird beim ersten Öffnen automatisch
|
||||
geladen. Tab-Wechsel mit ungespeicherten Änderungen löst einen Bestätigungsdialog aus.
|
||||
|
||||
Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und
|
||||
kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau
|
||||
ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf
|
||||
wird jedoch nicht technisch erkannt oder blockiert.
|
||||
|
||||
### Automatischer Scheduler
|
||||
|
||||
Der GUI-Tab „Scheduler" ermöglicht den Betrieb eines optionalen, periodisch laufenden
|
||||
Schedulers, der automatisch Verarbeitungsläufe anstößt.
|
||||
|
||||
**Konfigurationsparameter:**
|
||||
|
||||
| Parameter | Beschreibung | Standard |
|
||||
|---|---|---|
|
||||
| `scheduler.enabled` | Scheduler im GUI-Modus aktivieren (`true`/`false`); wird im headless Betrieb ignoriert | `false` |
|
||||
| `scheduler.interval.seconds` | Intervall zwischen automatischen Läufen in Sekunden (Integer >= 30; Pflicht wenn `scheduler.enabled=true`); wird im headless Betrieb ignoriert | – |
|
||||
|
||||
Ungültige Werte (kein Integer, < 30 oder leer bei `scheduler.enabled=true`) verhindern den
|
||||
Scheduler-Start und werden im GUI-Tab als Fehler gemeldet.
|
||||
|
||||
**Autostart:** Ist `scheduler.enabled=true` in der gespeicherten Konfiguration, startet der
|
||||
Scheduler automatisch, wenn die Konfiguration beim GUI-Start geladen wird. Der erste
|
||||
Verarbeitungslauf beginnt **unmittelbar** nach dem Scheduler-Start (kein initiales Warten).
|
||||
|
||||
**Headless-Betrieb:** Im headless Betrieb werden `scheduler.enabled` und
|
||||
`scheduler.interval.seconds` vollständig ignoriert. Der Scheduler ist ausschließlich im
|
||||
GUI-Modus verfügbar.
|
||||
|
||||
**Sperrverhalten:** Solange der Scheduler aktiv ist, ist Tab 1 „Konfiguration" gesperrt
|
||||
(Bearbeitungssperre mit Hinweisbanner). Manuelles Starten eines Laufs ist ebenfalls nicht
|
||||
möglich. Nach dem Stoppen des Schedulers werden beide Sperren automatisch aufgehoben.
|
||||
|
||||
**Schließen der Anwendung:** Versucht der Benutzer das Fenster zu schließen, während der
|
||||
Scheduler aktiv ist oder ein Lauf läuft, erscheint ein Informationsdialog. Das Schließen
|
||||
wird blockiert, bis der Scheduler gestoppt und kein Lauf mehr aktiv ist.
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
@@ -189,7 +235,7 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in:
|
||||
| `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) |
|
||||
| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) |
|
||||
| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) |
|
||||
| `max.title.length` | Maximale Länge des Basistitels in Zeichen (ganzzahlig, 10..120, Default 60). Werte unter 10 oder über 120 verhindern den Start. Werte 10–19 und 100–120 erzeugen eine Startwarnung. |
|
||||
| `max.title.length` | Maximale Länge des Basistitels in Zeichen (ganzzahlig, 10..120, Default 60). Werte unter 10 oder über 120 verhindern den Start. Werte 10–39 und 100–120 erzeugen eine Startwarnung. |
|
||||
| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) |
|
||||
|
||||
### Provider-Parameter
|
||||
@@ -222,6 +268,8 @@ Nur der **aktive** Provider muss vollständig konfiguriert sein. Der inaktive Pr
|
||||
| `log.directory` | Log-Verzeichnis | `./logs/` |
|
||||
| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` |
|
||||
| `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
|
||||
|
||||
@@ -292,6 +340,35 @@ Die Anwendung ergänzt den Prompt automatisch um:
|
||||
- einen Dokumenttext-Abschnitt
|
||||
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
|
||||
|
||||
### Prompt-Pfad-Auflösung je Betriebsart
|
||||
|
||||
Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst,
|
||||
wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab:
|
||||
|
||||
| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert |
|
||||
|---|---|---|
|
||||
| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` |
|
||||
| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` |
|
||||
| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` |
|
||||
| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen |
|
||||
|
||||
> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad**
|
||||
> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis
|
||||
> immer eindeutig auffindbar – insbesondere beim Start über den Windows Task Scheduler,
|
||||
> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann.
|
||||
|
||||
### Bearbeitung über den GUI-Prompt-Tab
|
||||
|
||||
Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und
|
||||
gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn
|
||||
das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem
|
||||
Fall wird kein stiller Fallback durchgeführt).
|
||||
|
||||
Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert
|
||||
war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"),
|
||||
aktualisiert sich der Prompt-Tab nicht automatisch – in diesem Fall sollte die GUI neu gestartet
|
||||
oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden.
|
||||
|
||||
---
|
||||
|
||||
## Zielformat
|
||||
@@ -389,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`.
|
||||
|
||||
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`).
|
||||
|
||||
---
|
||||
|
||||
@@ -399,11 +496,50 @@ Die SQLite-Datei enthält:
|
||||
|
||||
- **Dokument-Stammsätze**: Gesamtstatus, Fehlerzähler, letzter Zieldateiname, Zeitstempel
|
||||
- **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.
|
||||
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
|
||||
@@ -451,6 +587,10 @@ benötigt keine separate Java-Installation auf dem Zielsystem. Das Shade-JAR ble
|
||||
primäre Distributionsartefakt; der MSI ist eine zusätzliche Option für Systeme ohne
|
||||
Java-Installation und für den Standard-Installationspfad nach `C:\Program Files\`.
|
||||
|
||||
> **Hinweis zur CI-Umgebung:** Der MSI-Build ist Windows-only (`jpackage` + WiX Toolset 3.x).
|
||||
> Jenkins läuft im Linux-Container auf dem Synology NAS und kann kein MSI erzeugen.
|
||||
> Der MSI-Build wird bewusst manuell auf der Windows-Entwicklungsmaschine ausgeführt.
|
||||
|
||||
**Voraussetzungen für den Installer-Build (nur auf der Entwicklungsmaschine):**
|
||||
- Windows x64
|
||||
- JDK 21 im PATH
|
||||
@@ -510,6 +650,74 @@ Installationsverzeichnis ab. **Der Betreiber muss diese Beispieldatei manuell na
|
||||
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
|
||||
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
|
||||
|
||||
**Empfehlung für Pfade im MSI-Betrieb:**
|
||||
|
||||
Für den MSI-Betrieb (Startmenü, Task Scheduler) müssen alle Dateipfade als **absolute Pfade**
|
||||
konfiguriert werden. Relative Pfade werden relativ zum Installationsverzeichnis
|
||||
`C:\Program Files\PDF KI Renamer\` aufgelöst, das **schreibgeschützt** ist. Dadurch
|
||||
schlagen Schreibversuche (Logs, SQLite-Datenbank, Lock-Datei) ohne Fehlermeldung fehl.
|
||||
|
||||
> **Warnung – Relative Pfade im MSI-Betrieb nicht verwenden:**
|
||||
> Pfade wie `./logs`, `./work/local/logs` oder `logs/` werden im MSI-Betrieb relativ
|
||||
> zum Installationsverzeichnis aufgelöst. Das Installationsverzeichnis ist für normale
|
||||
> Benutzerkonten schreibgeschützt. Log4j2 scheitert dann still, ohne eine sichtbare
|
||||
> Fehlermeldung zu erzeugen.
|
||||
|
||||
> **Warnung – Backslashes in `.properties`-Dateien:**
|
||||
> In Java-`.properties`-Dateien werden Backslashes (`\`) als Escape-Zeichen interpretiert.
|
||||
> Windows-Pfade wie `C:\Users\Funny\Logs` müssen entweder mit Forward-Slashes
|
||||
> (`C:/Users/Funny/Logs`) oder mit doppelten Backslashes (`C:\\Users\\Funny\\Logs`)
|
||||
> angegeben werden. Einfache Backslashes werden stillschweigend falsch interpretiert.
|
||||
|
||||
Betroffene Parameter:
|
||||
|
||||
| Parameter | Empfehlung |
|
||||
|---|---|
|
||||
| `log.directory` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/logs` |
|
||||
| `runtime.lock.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock` |
|
||||
| `prompt.template.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/prompts/template.txt` |
|
||||
| `sqlite.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/pdf-umbenenner.db` |
|
||||
|
||||
Das empfohlene Konfigurationsverzeichnis für alle schreibbaren Daten im MSI-Betrieb ist
|
||||
`C:\ProgramData\PDF KI Renamer\`, da dieses Verzeichnis standardmäßig für alle
|
||||
Benutzerkonten schreibbar ist und bei der Deinstallation erhalten bleibt.
|
||||
|
||||
**Diagnose: Log-Datei-Prüfpunkt in den technischen Tests**
|
||||
|
||||
Die technischen Tests (Schaltfläche „Technische Tests ausführen" im Konfigurationseditor)
|
||||
enthalten einen dedizierten Prüfpunkt **„Log-Verzeichnis beschreibbar"**, der anzeigt:
|
||||
|
||||
- den konfigurierten `log.directory`-Wert (roh und als aufgelöster absoluter Pfad),
|
||||
- ob das Verzeichnis vorhanden und beschreibbar ist,
|
||||
- den tatsächlichen Log-Dateipfad aus der laufenden Log4j2-Konfiguration.
|
||||
|
||||
Ein nicht beschreibbares Log-Verzeichnis wird als **Warnung** angezeigt, nicht als Fehler
|
||||
(die Anwendung kann ohne Datei-Logging laufen). Der Prüfpunkt hilft, den typischen
|
||||
MSI-Betriebsfehler – relatives `log.directory` auf schreibgeschütztem Installationspfad –
|
||||
frühzeitig zu erkennen.
|
||||
|
||||
### MSI-Release-Checkliste
|
||||
|
||||
Die folgende Checkliste ist vor jeder MSI-Auslieferung manuell abzuarbeiten.
|
||||
|
||||
- [ ] Neuinstallation auf sauberer Windows-Umgebung ohne vorinstalliertes Java
|
||||
- [ ] Installation in Installationspfad **mit Leerzeichen** (z. B. `C:\Program Files\PDF KI Renamer\`)
|
||||
- [ ] Upgrade von installiertem Vorgänger-MSI (kein manuelles Deinstallieren)
|
||||
- [ ] GUI-Start über Startmenü-Eintrag
|
||||
- [ ] Headless-Start über `PDF-KI-Renamer.bat` im Windows Task Scheduler
|
||||
- [ ] Desktop-Shortcut vorhanden oder Einschränkung hier dokumentiert
|
||||
- [ ] App-Version `3.0.x` im Windows-Installer sichtbar („Programme und Features")
|
||||
- [ ] Deinstallation sauber – Konfiguration unter `C:\ProgramData\PDF KI Renamer\` bleibt erhalten
|
||||
- [ ] SmartScreen-Warnung erscheint und wird durch „Weitere Informationen → Trotzdem ausführen" bestätigt
|
||||
- [ ] BAT-Dateien funktionieren bei Installationspfad mit Leerzeichen
|
||||
- [ ] Anwendungsstart **ohne Entwicklungs-JDK** erfolgreich: GUI-Start, PDF laden und rendern, Verarbeitungslauf starten, Verlaufs-Tab öffnen (Verifikation der `addModules`-Liste)
|
||||
|
||||
> **Hinweis zur JDK-freien Laufzeit-Verifikation:** Nur ein erfolgreicher Test
|
||||
> auf einem System ohne installiertes JDK bestätigt die Vollständigkeit der
|
||||
> `addModules`-Liste in `pdf-umbenenner-packaging/pom.xml`. Die aktuelle Liste
|
||||
> wurde per `jdeps --print-module-deps --ignore-missing-deps` ermittelt;
|
||||
> vollständige Ausgabe in `pdf-umbenenner-packaging/jdeps-output.txt`.
|
||||
|
||||
### Build-Kommandos
|
||||
|
||||
**Vollständiger Reactor-Build** (alle Module, Tests, Packaging):
|
||||
@@ -606,7 +814,7 @@ Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md)
|
||||
- Keine eingebaute OCR-Funktion
|
||||
- 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
|
||||
- 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
|
||||
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
|
||||
- Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# V2.9-Freigabe
|
||||
|
||||
## Geprüfter Stand
|
||||
|
||||
- Git-Branch: `main`
|
||||
- Git-Commit (HEAD, zum Zeitpunkt der Prüfung): `6ff463b7efd935960c246dd48f9c55906699a82d`
|
||||
- Datum der Prüfung: 2026-04-28
|
||||
|
||||
---
|
||||
|
||||
## Umfang gegenüber V2.0
|
||||
|
||||
V2.9 ist die erste umfangreiche Funktionserweiterung nach dem V2.0-Abschluss.
|
||||
Der Schwerpunkt liegt auf dem neuen Tab „Verarbeitungslauf", der PDF-Vorschau,
|
||||
dem editierbaren Dateinamen-Bereich und der Kommunikation von Verarbeitungsergebnissen
|
||||
an den Benutzer.
|
||||
|
||||
### Neu in V2.9
|
||||
|
||||
| Thema | Issues | Beschreibung |
|
||||
|---|---|---|
|
||||
| Tab „Verarbeitungslauf" (Grundstruktur) | #20, #21 | Zweiter Tab mit Ergebnistabelle, Detailbereich und PDF-Vorschau; Anwendungs-Icon und System-Tray |
|
||||
| PDF-Vorschau (PDFBox-Migration) | #27, #29 | Direktes Rendering via `PDFRenderer.renderImageWithDPI`; Lazy Rendering mit In-Memory-Cache; Mausrad-Navigation |
|
||||
| Vollbild-Start | #28 | `stage.setMaximized(true)` beim GUI-Start |
|
||||
| Letzte Konfiguration automatisch laden | #33 | `java.util.prefs.Preferences` (`lastConfigPath`) |
|
||||
| Historischer Dateiname für SKIPPED-Dokumente | #41 | Spalte „Neuer Dateiname" zeigt historischen KI-Vorschlag für übersprungene Einträge |
|
||||
| Detailbereich für SKIPPED-Zeilen | #30 | `GuiHistoricalDocumentContextPort` liefert historischen Kontext; Detailbereich zeigt Datum, Name und Reasoning aus früherem Lauf |
|
||||
| Manuelle Dateinamen-Eingabe (nicht verarbeitete Dateien) | #31 | Dateiname-Editor für `FAILED_RETRYABLE`, `FAILED_PERMANENT`, `SKIPPED_FINAL_FAILURE` zur manuellen Kopie |
|
||||
| Benutzerfreundliche Fehlermeldungen | #43 | `AiFailureMessageTranslator` übersetzt technische Fehler für `FAILED`-Einträge ins Deutsche |
|
||||
| Differenzierte Status-Icons mit Farben | #44 | Unicode-Symbole `✓ ↻ × ≡ ⊘ ⟳` mit farbiger CSS-Darstellung statt Emoji |
|
||||
| Einzelinstanz-Schutz | #35 | Loopback-ServerSocket verhindert parallele Instanzen; zweite Instanz beendet sich sofort |
|
||||
| UX-Fixes im Detailbereich | #39, #40, #45, #46, #47 | Abstände, Button-Deaktivierung, Hinweisbereich |
|
||||
| Konfigurationsbereich kompakter | #24 | Layout-Optimierungen im Konfigurationstab |
|
||||
| Legacy-Datumsformat-Behandlung | #48 | `stringToInstant()`-Fehlerbehandlung; korrekte Abschlussmeldung bei SKIPPED-only-Läufen |
|
||||
| Prompt-Optimierung bei Zeichenlimit | #42 | Prompt weist KI explizit zur Kürzung auf konfiguriertes Zeichenlimit an |
|
||||
|
||||
---
|
||||
|
||||
## Ausgeführte Prüfungen
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
|---|---|
|
||||
| Vollständiger Maven-Reactor-Build (`clean verify`, alle 6 Module, `-DskipPitest=true`) | **ERFOLGREICH** |
|
||||
| Unit-Tests gesamt | **siehe Tabelle** |
|
||||
| Shaded-JAR erzeugt unter `pdf-umbenenner-bootstrap/target/` | **ja** |
|
||||
| Architekturkonsistenz (kein JavaFX in Domain/Application, keine Adapter-zu-Adapter-Abhängigkeiten) | **ja** |
|
||||
| Naming-Regel (keine M/AP/V-Bezeichner in Code) | **ja** |
|
||||
| Dokumentation (`gui-bedienanleitung.md`, `betrieb.md`) auf Konsistenz mit Implementierung geprüft | **ja** |
|
||||
|
||||
---
|
||||
|
||||
## Build- und Test-Ergebnisse
|
||||
|
||||
Ausgeführtes Kommando:
|
||||
```
|
||||
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make -DskipPitest=true
|
||||
```
|
||||
|
||||
**Gesamtergebnis: BUILD SUCCESS**
|
||||
|
||||
| Modul | Tests | Failures | Errors | Skipped |
|
||||
|---|---|---|---|---|
|
||||
| `pdf-umbenenner-domain` | 227 | 0 | 0 | 0 |
|
||||
| `pdf-umbenenner-application` | 455 | 0 | 0 | 0 |
|
||||
| `pdf-umbenenner-adapter-in-cli` | 8 | 0 | 0 | 0 |
|
||||
| `pdf-umbenenner-adapter-in-gui` | 190 | 0 | 0 | 0 |
|
||||
| `pdf-umbenenner-adapter-out` | 371 | 0 | 0 | 0 |
|
||||
| `pdf-umbenenner-bootstrap` | 147 | 0 | 0 | 0 |
|
||||
| **Gesamt** | **1.398** | **0** | **0** | **0** |
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
### #42 – Prompt-Kürzungsverhalten modellabhängig
|
||||
|
||||
Der Prompt weist die KI explizit an, bei Überschreitung des konfigurierten Zeichenlimits
|
||||
den Titel auf die zulässige Länge zu kürzen. Ob das Modell dieser Anweisung zuverlässig
|
||||
folgt, hängt vom eingesetzten Modell ab. Modelle mit schwacher Instruction-Following-Fähigkeit
|
||||
können das Limit ignorieren; in diesem Fall greift die bestehende serverseitige
|
||||
Validierung und der Versuch wird als Fehler klassifiziert.
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte (für nachfolgende Stufen)
|
||||
|
||||
Die folgenden Issues sind bekannt, aber nicht Release-Blocker für V2.9:
|
||||
|
||||
| Issue | Thema |
|
||||
|---|---|
|
||||
| #7 | Persistenz-Browser / Historienansicht in der GUI |
|
||||
| #22 | Kosten-Tracking und Token-Anzeige |
|
||||
| #23 | Weitere KI-Provider jenseits Claude / OpenAI-kompatibel |
|
||||
| #32 | Platzhalterbild in PDF-Vorschau bei fehlendem/ungültigem PDF |
|
||||
| #34 | Dokumentation des Tab-„Verarbeitungslauf"-Bedienkonzepts vervollständigen |
|
||||
| #44 | Icon-Farben unter bestimmten Windows-Systemthemen prüfen |
|
||||
| #49 | Abbruch eines laufenden Verarbeitungslaufs aus der GUI |
|
||||
| #50 | Fortschrittsanzeige während des Verarbeitungslaufs |
|
||||
| #51 | Filter- und Sortierfunktion in der Ergebnistabelle |
|
||||
|
||||
---
|
||||
|
||||
## Freigabeaussage
|
||||
|
||||
V2.9 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
|
||||
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
|
||||
bleibt unverändert gegenüber V2.0. Keine Release-Blocker.
|
||||
|
||||
Der vollständige Maven-Reactor-Build ist grün (1.398 Tests, 0 Failures, 0 Errors,
|
||||
0 Skipped). Die Dokumentation (`gui-bedienanleitung.md`, `betrieb.md`) ist auf
|
||||
den V2.9-Stand gebracht. Die bekannte Einschränkung (#42) ist dokumentiert
|
||||
und kein funktionaler Defekt.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Freigabedokument V3.0 – PDF-Umbenenner
|
||||
|
||||
## Geprüfter Stand
|
||||
|
||||
- Git-Branch: `main`
|
||||
- Versionsnummer: `3.0.238`
|
||||
- MSI-Datei: `PDF-KI-Renamer-3.0.238.msi`
|
||||
- Freigabedatum: 2026-05-05
|
||||
- **Status:** freigegeben
|
||||
|
||||
---
|
||||
|
||||
## Zielsetzung von V3.0
|
||||
|
||||
V3.0 ist kein Wechsel der Kernfunktion, sondern ein gezielter Qualitätssprung in drei
|
||||
Dimensionen: **Infrastruktur** (konsistente Versionierung, Flyway-DB-Migration,
|
||||
Jenkins-Stabilisierung, MSI-Vorbereitung), **Transparenz** (Historien-Tab, differenzierte
|
||||
Fehlerstatus-Darstellung, Lauf-Summary-Banner) und **Bedienkomfort** (Tooltips, Statuszeile,
|
||||
Prompt-Editor). Die fachliche Kernverarbeitung des PDF-Umbenenners – PDF lesen, KI benennen,
|
||||
Zieldatei kopieren – bleibt vollständig unverändert. Es wird kein neues Maven-Modul eingeführt;
|
||||
die hexagonale Architektur bleibt unangetastet.
|
||||
|
||||
---
|
||||
|
||||
## Umgesetzte Issues
|
||||
|
||||
| # | Commit | Kategorie | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| #67 | `c6379c0` | Infrastruktur | Konsistente Versionierung via Maven CI-friendly `${revision}`, MANIFEST.MF mit `Implementation-Version`, Fallback `dev` |
|
||||
| #68 | `500a8c5` | Infrastruktur | Jenkins-Build mit `-Drevision`-Übergabe, robuste Shade-JAR-Archivierung mit Bash und `mapfile` |
|
||||
| #49 | `732d00c` | Infrastruktur | Flyway-Integration mit V1-Basisskript, 3-Fall-Strategie (leer / Bestand baselined / regulärer Folgestart), `PRAGMA foreign_keys` per `SQLiteConfig`, Lock-Mechanismus, vollständige Schema-Prüfcheckliste, manuelle Schema-Evolution entfernt |
|
||||
| #51 | `563d9f5` | Fachlich/UX | Einheitliche Status-Darstellung mit Icon, Farbe, Tooltip; `FAILED_RETRYABLE` vs. `FAILED_FINAL` eindeutig differenziert |
|
||||
| #66 | `0fe5359` | UX | Tooltips auf Konfigurationstab, Verarbeitungslauf-Tab und Toolbar; zentrale `GuiTooltipTexts`-Konstantenklasse |
|
||||
| #73 | `dc17824` | GUI | Summary-Banner unterhalb Fortschrittsbalken nach Laufabschluss |
|
||||
| #50 | `4f5ce4c` | GUI | Statuszeile mit Version, Provider/Modell und Konfigurationsdateipfad |
|
||||
| #71 | `5d5dee0` | GUI | Prompt-Editor-Tab mit atomarem Speichern (`ATOMIC_MOVE`), Dirty-State, Default-Reset |
|
||||
| #7 | `46fc1d4` | GUI | Historien-Tab mit Liste, Detail, Filter, Status-Reset (feldgenau, Versuche bleiben) und destruktivem Löschen (Attempts vor Record in Transaktion) |
|
||||
| #65 | `51d6168` | Infrastruktur | MSI-Vorbereitung: jdeps-Modulliste, BAT-Dateien, `winUpgradeUuid`, Pfad-Hinweise in `betrieb.md` |
|
||||
|
||||
### Weitere Commits
|
||||
|
||||
| Commit | Beschreibung |
|
||||
|---|---|
|
||||
| `6e03093` | Architektur-Übersichten ergänzt (`domain-overview.md`, `gui-overview.md`, `adapter-overview.md`) |
|
||||
| `4b89743` | Bedienanleitung auf neuen Stand gebracht |
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Bilanz
|
||||
|
||||
| Neu | Anzahl | Bemerkung |
|
||||
|---|---|---|
|
||||
| Outbound-Ports | 1 | `HistoryQueryPort` |
|
||||
| Application-Use-Cases | 5 | `DefaultPromptEditorUseCase`, `DefaultHistoryOverviewUseCase`, `DefaultHistoryDetailsUseCase`, `DefaultHistoryResetDocumentStatusUseCase`, `DefaultDeleteDocumentHistoryUseCase` |
|
||||
| Outbound-Adapter | 2 | `SqliteHistoryQueryAdapter`, `FilesystemPromptPortAdapter.savePrompt` |
|
||||
| GUI-Bridge-Interfaces | 5 | `GuiPromptEditorPort`, `GuiHistoryOverviewPort`, `GuiHistoryDetailsPort`, `GuiHistoryResetDocumentStatusPort`, `GuiDeleteDocumentHistoryPort` |
|
||||
| GUI-Tabs | 2 | „Verlauf", „Prompt" |
|
||||
| GUI-Komponenten | 5 | `GuiStatusBar`, `BatchRunSummaryBanner`, `GuiHistoryTab`, `GuiPromptEditorTab`, `ProcessingStatusPresentation` |
|
||||
| Bootstrap | 1 + Erweiterung | `ApplicationVersionProvider` und Erweiterung des `GuiStartupContext` (`applicationVersion`, 5 neue Port-Felder) |
|
||||
| Datenbank-Migration | – | Flyway-V1-Basisskript, 3-Fall-Strategie, FK-Pragma per `SQLiteConfig`, Lock-Mechanismus |
|
||||
|
||||
Nicht geändert: `pdf-umbenenner-domain`, `pdf-umbenenner-adapter-in-cli`, headless-Betrieb.
|
||||
Bootstrap ausschließlich um MANIFEST.MF-Einträge und neue Bridge-Verdrahtung erweitert.
|
||||
|
||||
---
|
||||
|
||||
## Verbindlich verifizierte Spec-Punkte
|
||||
|
||||
- `${revision}` wird durch `flatten-maven-plugin` (`resolveCiFriendliesOnly`) aufgelöst;
|
||||
installierte POMs enthalten kein unaufgelöstes `${revision}`
|
||||
- MANIFEST.MF im Fat-JAR trägt `Implementation-Version`; Laufzeit-Fallback ist `dev`
|
||||
- `evolveTableColumns()` vollständig aus dem Code entfernt; Flyway ist die einzige
|
||||
Schema-Evolutionsquelle
|
||||
- Status-Reset setzt feldgenau `overall_status='READY_FOR_AI'`,
|
||||
`content_error_count=0`, `transient_error_count=0`, `last_failure_instant=NULL`;
|
||||
Versuche (`processing_attempt`) bleiben vollständig unangetastet
|
||||
- Tab-Reihenfolge: `Konfiguration | Verarbeitungslauf | Verlauf | Prompt`
|
||||
- `PromptPort.savePrompt` bleibt pfadfrei in der Port-Signatur (Hexagonal-konform;
|
||||
Pfadauflösung liegt im Adapter)
|
||||
- Farbe ist niemals das einzige Unterscheidungsmerkmal; alle Status tragen Icon und Text
|
||||
|
||||
---
|
||||
|
||||
## Headless-Kompatibilität
|
||||
|
||||
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten. Die
|
||||
`.properties`-Datei bleibt die einzige Konfigurationswahrheit. GUI-Code initialisiert
|
||||
den headless Pfad nicht. Keine stillen Änderungen an Retry-Semantik, Status-Persistenz
|
||||
oder fachlicher Verarbeitungslogik.
|
||||
|
||||
---
|
||||
|
||||
## Datenbank-Migration
|
||||
|
||||
Bestehende Datenbestände aus dem Vorgängerstand werden beim ersten Start der 3-Fall-Strategie
|
||||
unterworfen:
|
||||
|
||||
- **Neue DB** (keine Tabellen vorhanden): Flyway führt `V1__initial_schema.sql` vollständig aus.
|
||||
- **Bestand ohne Flyway-History** (typische Vorgänger-DB): vollständige Schema-Prüfcheckliste
|
||||
gegen das V1-Zielschema; bei konformem Schema wird eine datierte Backup-Kopie der
|
||||
`.sqlite`-Datei erstellt, danach Baseline auf V1 gesetzt. Bei nicht konformem Schema
|
||||
bricht der Start mit klarer Fehlermeldung ab – kein stilles Weiterlaufen.
|
||||
- **Bestand mit Flyway-History** (regulärer Folgestart): `migrate()` läuft idempotent.
|
||||
|
||||
`baselineOnMigrate=true` wird ausschließlich in Fall 2 gesetzt.
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte (vor finalem Release)
|
||||
|
||||
| Thema | Beschreibung |
|
||||
|---|---|
|
||||
| MSI-Testmatrix | Manueller MSI-Build und vollständige Abarbeitung der Testmatrix auf Windows-Maschine erforderlich; insbesondere Anwendungsstart **ohne JDK** zur Verifikation der `addModules`-Liste |
|
||||
| `winUpgradeUuid` | Der GUID `EA8D0149-1401-4D3D-A98D-A2B98DAE5495` wurde im Rahmen von #65 neu generiert. Vor dem ersten produktiven MSI-Release ist sicherzustellen, dass kein bisheriges produktives MSI mit einem abweichenden GUID ausgeliefert wurde – andernfalls bricht der MSI-Upgrade-Mechanismus. Nach Bestätigung „nie produktiv ausgeliefert" ist der GUID damit gesetzt und darf nie wieder geändert werden. |
|
||||
| Manueller GUI-Produkttest | Erfolgreicher Build und grüne Tests ersetzen keinen End-to-End-Lauf gegen einen echten KI-Provider mit echten PDFs. |
|
||||
| Finale Freigabe | `freigabe-v3_0.md` wird nach abgeschlossenem manuellen Produkttest und MSI-Verifikation in den Status „freigegeben" überführt. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht in V3.0
|
||||
|
||||
- Automatischer Scheduler / Quellordner-Überwachung
|
||||
- Token- und Kosten-Tracking
|
||||
- Excel-Export
|
||||
- Automatische Update-Prüfung
|
||||
- Dark Mode
|
||||
- Log-Viewer
|
||||
- PDF-Viewer Render-DPI-Konfiguration
|
||||
- Zoom per Mausrad
|
||||
- Hilfe-Datei F1
|
||||
- Änderungen an der fachlichen Kernverarbeitung des PDF-Umbenenners
|
||||
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
|
||||
|
||||
---
|
||||
|
||||
## Freigabeaussage
|
||||
|
||||
V3.0 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
|
||||
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
|
||||
bleibt unverändert gegenüber dem Vorgängerstand. Keine Release-Blocker für die
|
||||
Implementierungs-Freigabe.
|
||||
|
||||
Die finale Release-Freigabe steht aus bis zur vollständigen Abarbeitung der
|
||||
MSI-Testmatrix (insbesondere Verifikation des Anwendungsstarts ohne JDK),
|
||||
Klärung des `winUpgradeUuid`-Erstauslieferungsstatus und abgeschlossenem
|
||||
manuellem GUI-Produkttest gegen einen echten KI-Provider.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Freigabedokument V3.1 – PDF-Umbenenner
|
||||
|
||||
## Geprüfter Stand
|
||||
|
||||
- Git-Branch: `main`
|
||||
- Versionsnummer: `3.1.267`
|
||||
- Freigabedatum: 2026-05-06
|
||||
- **Status:** freigegeben
|
||||
|
||||
---
|
||||
|
||||
## Zielsetzung von V3.1
|
||||
|
||||
V3.1 ist der konsequente Nachschlag zu V3.0: Was der Produkttest aufgedeckt hat,
|
||||
wird bereinigt. Kein großes Architektur-Feature, kein neues Maven-Modul –
|
||||
gezielter UX-Schliff und Robustheit in drei Schwerpunkten:
|
||||
|
||||
1. **UX-Polishing** – sichtbare Schwächen aus dem V3.0-Produkttest behoben
|
||||
(#77, #80, #81, #83, #84, #88, #91)
|
||||
2. **Verlauf-Tab reifen lassen** – Suche, Mehrfachauswahl, DB-Neuanlage
|
||||
(#82, #86, #87)
|
||||
3. **Quick Win** – Mausrad-Zoom im PDF-Viewer als wertvoller Gebrauchskomfort
|
||||
(#32)
|
||||
|
||||
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt vollständig unverändert.
|
||||
Hexagonale Architektur, Modulstruktur, headless-Betrieb, `.properties`-
|
||||
Konfigurationswahrheit und Flyway-DB-Evolution bleiben unangetastet.
|
||||
|
||||
---
|
||||
|
||||
## Umgesetzte Issues
|
||||
|
||||
| # | Kategorie | Beschreibung |
|
||||
|---|---|---|
|
||||
| #32 | GUI | Strg+Mausrad-Zoom in der PDF-Vorschau: Delta-Akkumulation für Trackpad-Kompatibilität, ScrollEvent bei Strg immer konsumiert, Zoom 10–500 %, Viewport-Mitte bleibt beim Zoom stabil, Fit-to-Width-Modus nach manuellem Zoom verlassen; Grab & Pan mit Handcursor im vergrößerten Zustand |
|
||||
| #77 | UX | Vollständige Bestandsaufnahme aller interaktiven Elemente auf allen Tabs; fehlende Tooltips auf allen vier Tabs ergänzt; neue Konstanten ausschließlich in `GuiTooltipTexts`; TableColumn-Header über Column-Graphic-Pattern mit Label und Tooltip (kein Skin-/Lookup-Hack) |
|
||||
| #80 | UX | Dirty-Indikator für den Konfigurations-Tab: Asterisk im Tab-Titel bei echter Nutzeränderung gegenüber Baseline-Snapshot; `loadingInProgress`-Flag verhindert unechte Dirty-State-Auslösung durch programmgesteuertes Laden; Bestätigungsdialog beim Verlassen mit ungespeicherten Änderungen; Kopplung mit DB-Pfad-Wechsel aus #87 |
|
||||
| #81 | UX | Status-ComboBox und Versuche-Tabelle zeigen lesbare deutsche Anzeigetexte statt Enum-Rohnamen; alle acht Statuswerte über `ProcessingStatusPresentation` abgebildet; Status-ComboBox mit „Alle Status" als GUI-internem Null-Filter; DB-Queries intern weiterhin mit Enum-Namen |
|
||||
| #82 | GUI | Live-Filter im Verlauf-Tab: 300 ms Debounce-Timer, Generation-Counter für Race-Condition-Schutz, veraltete Worker-Ergebnisse werden verworfen; Such-Button und Enter starten Suche sofort; Auswahl nach jeder neuen Suche vollständig geleert |
|
||||
| #83 | UX | Leere KI-Begründung im Detailbereich zeigt `promptText`-Platzhalter statt leerem Feld; kein Vermischen von Nutzdaten und UI-Platzhaltertext; TextArea bleibt sichtbar |
|
||||
| #84 | Bug | Aktionsbuttons im Verlauf-Tab werden nach Laufende ereignisgetrieben reaktiviert – unabhängig vom Terminierungsgrund (Erfolg, Fehlerabbruch, Nutzerabbruch, Leerlauf); kein manueller Workaround notwendig |
|
||||
| #86 | GUI | Mehrfachauswahl im Verlauf-Tab: `SelectionMode.MULTIPLE`, Strg+A nur bei Tabellenfokus (kein Konflikt mit Suchfeld), Schlüssel-Snapshot vor Worker-Thread-Start, Bulk-Reset und Bulk-Delete mit Bestätigungsdialog und Partial-Success-Zusammenfassung; Detailbereich zeigt Platzhalter bei Mehrfachauswahl |
|
||||
| #87 | GUI | Neuer Menüpunkt „Datenbank → Neue Datenbank anlegen...": atomarer Ablauf via Temp-Datei, Flyway auf neuesten Schema-Stand, Verbindungstest, atomarer Move mit `ATOMIC_MOVE + REPLACE_EXISTING`; normalisierter case-insensitiver Pfadvergleich; DB-Busy-Sperre; Konfig-Tab wechselt in Dirty-State; Hinweismeldung nach Wechsel |
|
||||
| #88 | UX | Fehlerursache für `FAILED_FINAL`, `FAILED_RETRYABLE` und `SKIPPED_FINAL_FAILURE` im Verlauf-Tab sichtbar; Flyway-Migration ergänzt Spalte `failure_details` in `processing_attempt`; Begrenzung auf 1000 Zeichen mit „…"-Kürzung vor Persistierung; keine rohen Provider-Meldungen oder API-Schlüssel persistiert; NULL-Einträge zeigen `promptText`-Platzhalter |
|
||||
| #91 | Robustheit | Lock-File-Pfadauflösung: absoluter Pfad direkt ohne Fallback (Abbruch bei Fehler); relativer oder unkonfigurierter Pfad zweistufig (JAR-Verzeichnis → `user.home` → Abbruch); fehlende Parent-Verzeichnisse automatisch angelegt; tatsächlich verwendeter absoluter Pfad beim Start auf INFO-Level geloggt; gilt für GUI- und headless Start |
|
||||
|
||||
### Nachbesserung aus dem Produkttest
|
||||
|
||||
| # | Beschreibung |
|
||||
|---|---|
|
||||
| #93 | Produkttest-Nachbesserung: Korrekturen und Feinabstimmungen nach abgeschlossenem manuellem GUI-Produkttest gegen echte KI-Provider und echte PDFs |
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Bilanz
|
||||
|
||||
| Neu | Anzahl | Bemerkung |
|
||||
|---|---|---|
|
||||
| Inbound-Port-Interfaces | 1 | `CreateNewDatabaseUseCase` |
|
||||
| Application-Use-Cases | 1 | `DefaultCreateNewDatabaseUseCase` |
|
||||
| Outbound-Ports | 2 | `DatabaseCreationPort`, `ActiveDatabaseContextPort` |
|
||||
| Outbound-Adapter | 2 | `SqliteDatabaseCreationAdapter`, `SqliteActiveDatabaseContextAdapter` |
|
||||
| GUI-Bridge-Interfaces | 1 | `GuiCreateNewDatabasePort` |
|
||||
| Flyway-Migration | 1 | `failure_details TEXT` in `processing_attempt` (nächste freie Versionsnummer) |
|
||||
|
||||
Geänderte Komponenten (ausschließlich `adapter-in-gui`):
|
||||
`GuiHistoryTab`, `GuiConfigTab`, `GuiTooltipTexts`, Verlauf-Detailbereich,
|
||||
Status-ComboBox, PDF-Vorschau-Komponente, Lauf-Abschluss-Signalkette.
|
||||
|
||||
Nicht geändert: `pdf-umbenenner-domain` (außer ggf. minimaler Erweiterung für #88),
|
||||
`pdf-umbenenner-adapter-in-cli`, headless-Verarbeitungslogik, fachliche Kernverarbeitung.
|
||||
|
||||
---
|
||||
|
||||
## Verbindlich verifizierte Spec-Punkte
|
||||
|
||||
- Kein Enum-Rohname in der GUI sichtbar – alle acht Statuswerte tragen Displaytext
|
||||
- `promptText` für leere Felder: kein Vermischen von Nutzdaten und Platzhaltertext
|
||||
- Dirty-State Konfig-Tab: programmgesteuertes Laden löst kein Dirty-Flag aus
|
||||
- Live-Filter: 300 ms Debounce, Generation-Counter, Auswahl nach Suche geleert
|
||||
- Strg+A im Verlauf-Tab: nur bei Tabellenfokus (kein Konflikt mit Suchfeld)
|
||||
- Schlüssel-Snapshot vor Bulk-Worker-Thread-Start
|
||||
- DB-Anlage: normalisierter Pfadvergleich (case-insensitive, `toRealPath`/Parent-Normalisierung)
|
||||
- DB-Anlage: `ATOMIC_MOVE + REPLACE_EXISTING`; kein halb-atomarer Fallback
|
||||
- DB-Anlage: aktive DB bleibt bei Fehler vollständig unverändert
|
||||
- Lock-File: absoluter Pfad direkt; relativer Pfad zweistufig; Pfad geloggt (INFO)
|
||||
- Strg+Mausrad: ScrollEvent immer konsumiert; Delta-Akkumulation; 10–500 %
|
||||
- `failure_details`: max. 1000 Zeichen vor Persistierung; keine rohen Provider-Meldungen
|
||||
- Aktionsbuttons nach Laufende ereignisgetrieben reaktiviert (alle Terminierungsgründe)
|
||||
- Flyway ist die einzige Schema-Evolutionsquelle – kein manuelles DDL im Code
|
||||
- Code-Kommentare auf Deutsch; Logging auf Deutsch
|
||||
- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
|
||||
|
||||
---
|
||||
|
||||
## Headless-Kompatibilität
|
||||
|
||||
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten.
|
||||
Die `.properties`-Datei bleibt die einzige Konfigurationswahrheit. GUI-Code
|
||||
initialisiert den headless Pfad nicht. Keine stillen Änderungen an Retry-Semantik,
|
||||
Status-Persistenz oder fachlicher Verarbeitungslogik.
|
||||
|
||||
Von V3.1-Änderungen betroffener headless-Pfad: Lock-File-Pfadauflösung (#91)
|
||||
und Flyway-Schemamigration für `failure_details` (#88) – beide wirken beim
|
||||
Programmstart unabhängig von GUI oder CLI.
|
||||
|
||||
---
|
||||
|
||||
## Datenbank-Migration
|
||||
|
||||
Flyway ergänzt die Tabelle `processing_attempt` um die Spalte `failure_details`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
|
||||
```
|
||||
|
||||
- Bestehende Zeilen erhalten automatisch `NULL` – kein Datenverlust.
|
||||
- Ältere Einträge ohne Fehlerdetails zeigen in der GUI einen `promptText`-Platzhalter.
|
||||
- Kein SQL-`CHECK`-Constraint (um Importdaten nicht zu blockieren).
|
||||
- Begrenzung auf 1000 Zeichen wird ausschließlich vor Persistierung im Adapter erzwungen.
|
||||
|
||||
---
|
||||
|
||||
## Produkttest
|
||||
|
||||
**Produkttest: bestanden**
|
||||
|
||||
Manueller GUI-Produkttest gegen echte KI-Provider mit echten PDFs abgeschlossen.
|
||||
Alle elf Issues und die Nachbesserung #93 wurden end-to-end verifiziert.
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
Keine.
|
||||
|
||||
---
|
||||
|
||||
## Nicht in V3.1
|
||||
|
||||
- Automatischer Scheduler / Quellordner-Überwachung (#22) → V3.x
|
||||
- PDF-Viewer Render-DPI (#23) → V3.2
|
||||
- F1-Hilfe (#69) → V3.2
|
||||
- Dark Mode (#70) → V3.x
|
||||
- Log-Viewer in der GUI (#72) → V3.2
|
||||
- Token- und Kosten-Tracking (#74) → V3.2
|
||||
- Excel-Export (#75) → V3.2
|
||||
- Automatische Update-Prüfung (#76) → V3.2
|
||||
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
|
||||
- Änderung der fachlichen Kernverarbeitung des PDF-Umbenenners
|
||||
|
||||
---
|
||||
|
||||
## Nächste Version
|
||||
|
||||
**V3.2** – geplante Schwerpunkte: PDF-Viewer Render-DPI, F1-Hilfe, Log-Viewer,
|
||||
Token- und Kosten-Tracking, Excel-Export, automatische Update-Prüfung.
|
||||
|
||||
---
|
||||
|
||||
## Freigabeaussage
|
||||
|
||||
V3.1 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
|
||||
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
|
||||
bleibt unverändert gegenüber V3.0. Manueller Produkttest bestanden.
|
||||
Keine Release-Blocker.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Freigabedokument V3.2 – PDF-Umbenenner
|
||||
|
||||
## Geprüfter Stand
|
||||
|
||||
- Git-Branch: `main`
|
||||
- Versionsnummer: `3.2.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.
|
||||
+583
-27
@@ -1,4 +1,4 @@
|
||||
# GUI-Bedienanleitung – PDF-Umbenenner V2.0
|
||||
# GUI-Bedienanleitung – PDF-Umbenenner
|
||||
|
||||
Diese Anleitung beschreibt die JavaFX-Desktop-GUI des PDF-Umbenenners. Sie richtet sich an
|
||||
Endbenutzer und Betreuer, die die Konfiguration der Anwendung über die grafische Oberfläche
|
||||
@@ -8,18 +8,23 @@ verwalten und technisch prüfen möchten.
|
||||
|
||||
## 1. Zweck und Scope der GUI
|
||||
|
||||
Die GUI gliedert sich in zwei feste Tabs:
|
||||
Die GUI gliedert sich in fünf feste Tabs:
|
||||
|
||||
- **Tab 1 „Konfiguration"** – Editor, Validierungsoberfläche und technische
|
||||
Test-/Diagnoseoberfläche für die `.properties`-Datei.
|
||||
- **Tab 2 „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit
|
||||
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
|
||||
- **Tab 3 „Scheduler"** – Optionaler automatischer Scheduler für periodische
|
||||
Verarbeitungsläufe (siehe Abschnitt 14).
|
||||
- **Tab 4 „Verlauf"** – Ansicht aller bisher verarbeiteten Dokumente mit Status
|
||||
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).
|
||||
|
||||
Weiterhin **nicht** enthalten sind ein Historien-Tab, eine Datenbankansicht und ein
|
||||
Kosten-Tracking — diese Ausbauten sind für spätere Stufen vorbehalten.
|
||||
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 19).
|
||||
|
||||
Der headless Batch-/Scheduler-Betrieb über `--headless` bleibt der einzige Weg,
|
||||
PDF-Dateien automatisiert zu verarbeiten.
|
||||
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
|
||||
`--headless` der empfohlene Weg.
|
||||
|
||||
---
|
||||
|
||||
@@ -334,13 +339,23 @@ vorbelegt.
|
||||
|
||||
## 8. Dirty-State und Schutzdialoge
|
||||
|
||||
### Konfigurations-Tab
|
||||
|
||||
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:
|
||||
|
||||
- Ein **`*`**-Präfix im **Tab-Titel**: `* Konfiguration`
|
||||
- Ein **`*`**-Präfix im Fenstertitel
|
||||
- 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,
|
||||
ob ungespeicherte Änderungen vorhanden sind. Ist dies der Fall, erscheint ein
|
||||
Schutzdialog mit drei Optionen:
|
||||
@@ -351,6 +366,12 @@ Schutzdialog mit drei Optionen:
|
||||
| **Verwerfen** | Verwirft die Änderungen und führt die Aktion aus |
|
||||
| **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
|
||||
@@ -468,15 +489,31 @@ in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein.
|
||||
- Nach jeder abgeschlossenen Datei erscheint ohne manuellen Refresh eine neue Zeile mit
|
||||
den fünf Spalten **Status-Icon**, **Originaldateiname**, **Neuer Dateiname**, **Datum**
|
||||
und **Dauer**.
|
||||
- Für Fehler- und Übersprungen-Fälle wird bei den Spalten „Neuer Dateiname" und „Datum"
|
||||
ein Gedankenstrich `—` eingetragen.
|
||||
- Die Status-Icons folgen: ✅ erfolgreich, ⚠️ fehlgeschlagen (retryable),
|
||||
❌ fehlgeschlagen (permanent), ⏭️ übersprungen.
|
||||
- Ein Klick auf eine Zeile zeigt die KI-Begründung im Seitenbereich. Liegt keine
|
||||
Begründung vor, erscheint der Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning
|
||||
vor.".
|
||||
- Nach Laufende erscheint die Zusammenfassung `X erfolgreich, Y fehlgeschlagen,
|
||||
Z übersprungen` im Meldungs- und Zusammenfassungsbereich.
|
||||
- Für `FAILED_*`-Zeilen und `SKIPPED_FINAL_FAILURE`-Zeilen wird in den Spalten
|
||||
„Neuer Dateiname" und „Datum" ein Gedankenstrich `—` eingetragen.
|
||||
`SKIPPED_ALREADY_PROCESSED`-Zeilen zeigen in der Spalte „Neuer Dateiname" den
|
||||
historischen Zieldateinamen aus dem letzten erfolgreichen Lauf; „Datum" bleibt `—`.
|
||||
- Status-Icons (Unicode-Zeichen mit Farbe):
|
||||
|
||||
| Symbol | Farbe | Bedeutung |
|
||||
|--------|-------|-----------|
|
||||
| `✓` | Grün | Erfolgreich |
|
||||
| `↻` | Orange | Fehlgeschlagen (wiederholbar) |
|
||||
| `×` | Rot | Fehlgeschlagen (permanent) |
|
||||
| `≡` | Grau | Übersprungen (bereits erfolgreich verarbeitet) |
|
||||
| `⊘` | Dunkelgrau | Übersprungen (endgültig fehlgeschlagen) |
|
||||
| `⟳` | Grau | Zurückgesetzt – wartet auf nächsten Lauf |
|
||||
|
||||
Farbe ist niemals das einzige Unterscheidungsmerkmal – Icon und Tooltip beschreiben
|
||||
den Status auch ohne Farbwahrnehmung eindeutig. Die vollständige Status-Mapping-Tabelle
|
||||
mit Tooltips ist in Abschnitt 20 beschrieben.
|
||||
|
||||
- Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge
|
||||
zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des
|
||||
KI-Reasonings. Liegt weder Reasoning noch Fehlermeldung vor, erscheint der
|
||||
Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning vor.".
|
||||
- Nach Laufende erscheint das **Summary-Banner** unterhalb des Fortschrittsbalkens
|
||||
(siehe Abschnitt 13c).
|
||||
|
||||
### Soft-Stop
|
||||
Der Knopf **Abbrechen** löst einen **Soft-Stop** aus: die aktuell in Bearbeitung
|
||||
@@ -600,21 +637,67 @@ Das Panel enthält drei Bereiche:
|
||||
- **Seitennavigation:** Über die Schaltflächen **„◀"** und **„▶"** (oder das Mausrad)
|
||||
kann seitenweise geblättert werden. Die aktuelle Seitenzahl und Gesamtseitenzahl
|
||||
werden angezeigt.
|
||||
- **Fit-to-view:** Die Seite wird automatisch an die verfügbare Fläche angepasst
|
||||
(preserveRatio=true). Keine Scrollbalken, keine manuelle Zoom-Einstellung.
|
||||
- **Fit-to-Width:** Nach dem Laden wird die Seite automatisch an die verfügbare Breite
|
||||
angepasst (preserveRatio=true).
|
||||
- Das Rendering erfolgt direkt über Apache PDFBox bei 120 DPI.
|
||||
|
||||
### KI-Begründung
|
||||
#### Zoom per Mausrad (Strg+Mausrad)
|
||||
|
||||
Der mittlere Bereich zeigt das KI-Reasoning des ausgewählten Eintrags. Liegt kein
|
||||
Reasoning vor (z. B. bei Übersprungen-Einträgen), erscheint der Hinweis
|
||||
- **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
|
||||
|
||||
Der mittlere Bereich zeigt das KI-Reasoning des ausgewählten Eintrags.
|
||||
|
||||
Für Einträge mit Status `FAILED_*` wird – sofern kein KI-Reasoning vorliegt –
|
||||
stattdessen eine übersetzte Fehlermeldung angezeigt (Präfix `⚠`), zum Beispiel:
|
||||
|
||||
- „PDF enthält keinen lesbaren Text. Möglicherweise handelt es sich um einen Scan
|
||||
ohne Texterkennung (OCR). Eine automatische Benennung ist nicht möglich."
|
||||
- „KI-Dienst: Ungültiger API-Schlüssel. Bitte in den Einstellungen prüfen."
|
||||
- „KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen."
|
||||
|
||||
Für `SKIPPED_ALREADY_PROCESSED`-Einträge erscheint der Zeitpunkt des letzten
|
||||
erfolgreichen Verarbeitungslaufs, sofern er in der Datenbank vorliegt.
|
||||
|
||||
Liegt weder Reasoning noch Fehlermeldung vor, erscheint der Hinweis
|
||||
„Für diesen Eintrag liegt kein KI-Reasoning vor.".
|
||||
|
||||
### Editierbarer Dateiname
|
||||
|
||||
- Unterhalb des Reasoning-Bereichs befindet sich ein **editierbares Textfeld** mit
|
||||
dem aktuellen Dateinamen des Eintrags.
|
||||
- Das Feld kann direkt bearbeitet werden. Die Eingabe wird **live validiert**
|
||||
Unterhalb des Reasoning-Bereichs befindet sich ein **editierbares Textfeld** mit
|
||||
dem Dateinamen des ausgewählten Eintrags (ohne `.pdf`-Erweiterung; `.pdf` wird als
|
||||
nicht editierbares Label daneben angezeigt).
|
||||
|
||||
#### Aktivitätszustand je Zeilenstatus
|
||||
|
||||
| Zeilenstatus | Textfeld-Verhalten |
|
||||
|---|---|
|
||||
| Kein Eintrag selektiert | Leer, deaktiviert |
|
||||
| `SUCCESS` | Editierbar; letzter gespeicherter Name vorausgefüllt. Ermöglicht Umbenennung der vorhandenen Zieldatei. |
|
||||
| `SKIPPED_ALREADY_PROCESSED` | Editierbar (sofern historischer Dateiname vorhanden). Ermöglicht Umbenennung der vorhandenen Zieldatei. |
|
||||
| `FAILED_RETRYABLE`, `FAILED_PERMANENT`, `SKIPPED_FINAL_FAILURE` | Editierbar; Feld leer. Erlaubt Eingabe eines manuellen Dateinamens für eine direkte Kopie der Quelldatei. |
|
||||
| Zurückgesetzt (`⟳`) | Deaktiviert |
|
||||
| Lauf aktiv | Vollständig deaktiviert |
|
||||
|
||||
Das Feld kann direkt bearbeitet werden. Die Eingabe wird **live validiert**
|
||||
(Formatprüfung `YYYY-MM-DD - Titel.pdf`, Titelzeichen, Länge).
|
||||
- Fehlerhafte Eingaben werden direkt unter dem Feld als rote Meldung angezeigt.
|
||||
- **Speichern:** Der Button **„Übernehmen"** führt die Umbenennung durch – atomare
|
||||
@@ -629,13 +712,486 @@ Reasoning vor (z. B. bei Übersprungen-Einträgen), erscheint der Hinweis
|
||||
|
||||
---
|
||||
|
||||
## 14. Bekannte Einschränkungen V2.x
|
||||
## 13c. Summary-Banner nach Laufabschluss
|
||||
|
||||
Nach Abschluss eines Verarbeitungslaufs erscheint unterhalb des Fortschrittsbalkens und
|
||||
oberhalb der Ergebnistabelle ein einzeiliges **Summary-Banner** (`BatchRunSummaryBanner`).
|
||||
Es zeigt auf einen Blick, wie viele Dateien in welche Kategorie gefallen sind.
|
||||
|
||||
**Beispiel:**
|
||||
|
||||
```
|
||||
✓ 14 erfolgreich · ↻ 1 wird wiederholt · × 2 fehlgeschlagen · ≡ 3 übersprungen · ⊘ 1 endgültig übersprungen
|
||||
```
|
||||
|
||||
**Regeln:**
|
||||
|
||||
- Nur Kategorien mit Anzahl größer als 0 werden angezeigt.
|
||||
- Bei einem vollständig sauberen Lauf erscheint nur die Erfolgskategorie,
|
||||
z. B. `✓ 17 erfolgreich`.
|
||||
- Jedes Segment enthält Icon und Text – Farbe ist niemals das einzige Unterscheidungsmerkmal.
|
||||
- Das Banner verschwindet automatisch, wenn der nächste Lauf gestartet wird.
|
||||
- Das Banner erscheint **nicht** beim Anwendungsstart oder bei einem Tab-Wechsel
|
||||
ohne vorherigen Lauf.
|
||||
|
||||
**Kategorien:**
|
||||
|
||||
| Icon | Text | Entsprechender Status |
|
||||
|------|------|-----------------------|
|
||||
| `✓` | erfolgreich | `SUCCESS` |
|
||||
| `↻` | wird wiederholt | `FAILED_RETRYABLE` |
|
||||
| `×` | fehlgeschlagen | `FAILED_FINAL` |
|
||||
| `≡` | übersprungen | `SKIPPED_ALREADY_PROCESSED` |
|
||||
| `⊘` | endgültig übersprungen | `SKIPPED_FINAL_FAILURE` |
|
||||
|
||||
Die Zwischenstatus `READY_FOR_AI`, `PROPOSAL_READY` und `PROCESSING` werden im Banner
|
||||
nicht gezählt – sie treten nach Laufabschluss nicht mehr auf.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| Kein Historien-Tab | Eine Ansicht der SQLite-Datenbank und Verarbeitungshistorie ist für spätere Ausbaustufen vorbehalten |
|
||||
| Kein Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## 16. System-Tray
|
||||
|
||||
Wird das Hauptfenster über das Schließen-Symbol (oder Alt+F4) geschlossen, ohne dass
|
||||
ungespeicherte Änderungen oder ein aktiver Verarbeitungslauf vorliegen, **minimiert
|
||||
sich die Anwendung in den Windows System-Tray** statt sich zu beenden. Das Fenster
|
||||
bleibt im Hintergrund aktiv und ist über das Tray-Icon wieder erreichbar.
|
||||
|
||||
### 15.1 Tray-Icon-Menü
|
||||
|
||||
Ein **Rechtsklick** auf das Tray-Icon öffnet ein Kontextmenü:
|
||||
|
||||
| Eintrag | Wirkung |
|
||||
|---------|---------|
|
||||
| **Öffnen** | Bringt das Hauptfenster in den Vordergrund |
|
||||
| **Beenden** | Beendet die Anwendung vollständig |
|
||||
|
||||
Ein **Doppelklick** auf das Tray-Icon hat denselben Effekt wie „Öffnen".
|
||||
|
||||
### 15.2 Sonderfälle beim Schließen
|
||||
|
||||
| Situation | Verhalten |
|
||||
|---|---|
|
||||
| Ungespeicherte Änderungen | Schutzdialog erscheint zuerst (Speichern / Verwerfen / Abbrechen); erst nach Auflösung wird in den Tray minimiert |
|
||||
| Aktiver Verarbeitungslauf | Hinweisdialog erscheint (Abschnitt 13); nach Soft-Stop oder Abschluss kann in den Tray minimiert werden |
|
||||
| System-Tray nicht verfügbar | Fenster wird beim Schließen wie ohne Tray-Support behandelt; der Schutzdialog für ungespeicherte Änderungen bleibt aktiv |
|
||||
|
||||
---
|
||||
|
||||
## 17. Tab „Verlauf" (Historien-Tab)
|
||||
|
||||
Der dritte Tab **„Verlauf"** zeigt alle jemals verarbeiteten Dokumente mit Status,
|
||||
Dateinamen und Verarbeitungsdetails. Die Daten stammen direkt aus der SQLite-Datenbank,
|
||||
die in der geladenen Konfiguration angegeben ist.
|
||||
|
||||
### Layout
|
||||
|
||||
Das Tab ist zweigeteilt:
|
||||
|
||||
- **Linke Hälfte (~55%):** Dokumentenliste mit Filter-Bereich oben
|
||||
- **Rechte Hälfte (~45%):** Detailbereich zum ausgewählten Dokument
|
||||
|
||||
### Dokumentenliste
|
||||
|
||||
Die Tabelle zeigt folgende Spalten:
|
||||
|
||||
| Spalte | Inhalt |
|
||||
|--------|--------|
|
||||
| Status-Icon | Symbol und Farbe gemäß Status-Mapping-Tabelle (Abschnitt 20) |
|
||||
| Quelldateiname | Ursprünglicher Dateiname der PDF-Datei |
|
||||
| Zieldateiname | Zuletzt vergebener Dateiname nach Umbenennung |
|
||||
| Quellpfad | Letzter bekannter Quellordner |
|
||||
| Letzter Versuch | Zeitpunkt der letzten Statusänderung |
|
||||
| Anzahl Versuche | Gesamtzahl aller Verarbeitungsversuche |
|
||||
|
||||
**Sortierung:** Standardmäßig absteigend nach dem letzten Versuch (neueste zuerst).
|
||||
|
||||
**Hinweise zur Anzeige:**
|
||||
- Lange Dateinamen und Pfade werden in der Tabelle abgekürzt (Ellipsis). Der vollständige
|
||||
Text erscheint im Tooltip beim Hover.
|
||||
- Bei mehr als 500 Treffern erscheint der Hinweis „Weitere Einträge vorhanden – Filter
|
||||
verwenden". Es werden dann nur die 500 neuesten Einträge angezeigt.
|
||||
- Bei leerer Datenbank erscheint der Text „Noch keine Verarbeitungen vorhanden."
|
||||
|
||||
### Filter
|
||||
|
||||
Über dem Tab befinden sich drei Bedienelemente:
|
||||
|
||||
- **Freitextsuche** – filtert über Quelldateiname und Zieldateiname, case-insensitiv
|
||||
- **Status-Filter** – ComboBox zur Auswahl eines bestimmten Status oder „Alle Status"
|
||||
- **„Suchen"** – startet die Suche sofort; alternativ die Enter-Taste im Suchfeld
|
||||
|
||||
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
|
||||
|
||||
Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
|
||||
|
||||
**Dokument-Info:**
|
||||
- Fingerprint (12 Zeichen des SHA-256-Hash)
|
||||
- Quelldateiname und Quellpfad
|
||||
- Status (Icon + Text)
|
||||
- Erstellt am / Aktualisiert am
|
||||
|
||||
**Versuche-Tabelle:** Alle bisher protokollierten Verarbeitungsversuche:
|
||||
|
||||
| Spalte | Inhalt |
|
||||
|--------|--------|
|
||||
| # | Versuchsnummer |
|
||||
| Datum | Endzeitpunkt des Versuchs |
|
||||
| Status | Ergebnisstatus des Versuchs (lesbarer Anzeigetext, kein Enum-Rohname) |
|
||||
| Provider | Verwendeter KI-Provider |
|
||||
| Modell | Verwendetes Sprachmodell |
|
||||
| Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname |
|
||||
|
||||
**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
|
||||
|
||||
Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung.
|
||||
**Beide Aktionen unterstützen Mehrfachauswahl** (≥ 1 Eintrag):
|
||||
|
||||
**„Status zurücksetzen"**
|
||||
|
||||
Setzt den Status der ausgewählten Dokumente auf „Wartet auf Verarbeitung" zurück,
|
||||
sodass sie beim nächsten Verarbeitungslauf automatisch erneut verarbeitet werden.
|
||||
Die Versuchshistorie bleibt vollständig erhalten – kein Versuch wird gelöscht.
|
||||
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
|
||||
durchgeführt, Passwortschutz entfernt) und das Dokument erneut verarbeitet werden soll.
|
||||
|
||||
**„Eintrag löschen"**
|
||||
|
||||
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**.
|
||||
Vor der Aktion erscheint ein Bestätigungsdialog: „X Einträge unwiderruflich löschen?"
|
||||
|
||||
Bei Mehrfachauswahl gilt dieselbe Partial-Success-Logik wie beim Zurücksetzen.
|
||||
|
||||
**Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert.
|
||||
Nach Laufende werden die Buttons automatisch reaktiviert, sofern eine Auswahl besteht –
|
||||
ohne dass der Benutzer die Auswahl erneuern muss.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
KI-Prompt-Datei direkt in der GUI – ohne externen Editor.
|
||||
|
||||
### Inhalt und Bedienung
|
||||
|
||||
Die TextArea zeigt den aktuellen Inhalt der in der Konfiguration eingetragenen
|
||||
Prompt-Datei. Der Inhalt ist vollständig editierbar.
|
||||
|
||||
**Buttons:**
|
||||
|
||||
- **„Speichern"** – schreibt den aktuellen Inhalt atomar in die Prompt-Datei
|
||||
(Temp-Datei im selben Verzeichnis, dann atomarer Austausch). Encoding: UTF-8;
|
||||
Zeilenenden werden unverändert übernommen. Bei einem Fehler erscheint eine
|
||||
Fehlermeldung im Tab; es gibt keinen stillen Fallback.
|
||||
- **„Auf Standard zurücksetzen"** – füllt die TextArea mit dem eingebauten
|
||||
Standard-Template, ohne die Datei sofort zu speichern. Erst ein anschließendes
|
||||
„Speichern" schreibt die Änderung auf den Datenträger.
|
||||
|
||||
**Dirty State:**
|
||||
|
||||
Sobald der TextArea-Inhalt vom gespeicherten Stand abweicht, erscheint ein
|
||||
Asterisk im Tab-Titel: **„Prompt \*"**. Wird der Tab gewechselt oder die
|
||||
Anwendung geschlossen, während ungespeicherte Änderungen vorliegen, erscheint
|
||||
ein Bestätigungsdialog mit der Frage „Änderungen verwerfen?".
|
||||
|
||||
### Fehlende Prompt-Datei
|
||||
|
||||
Ist keine Prompt-Datei konfiguriert oder existiert die konfigurierte Datei nicht,
|
||||
zeigt der Tab einen Hinweistext und den Button **„Standard-Prompt erstellen"**.
|
||||
Ein Klick legt eine Prompt-Datei mit dem deutschen Standard-Template an
|
||||
(standardmäßig im selben Ordner wie die geladene `.properties`-Datei).
|
||||
|
||||
### Hinweise
|
||||
|
||||
- Das Tab lädt den Dateiinhalt automatisch, wenn es geöffnet wird (Hintergrund-Thread).
|
||||
- Wird die Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht.
|
||||
Beim Speichern gilt Last-write-wins.
|
||||
- Für den Betrieb über MSI oder Task Scheduler wird empfohlen, den Prompt-Pfad
|
||||
in der Konfiguration als absoluten Pfad anzugeben, um vom jeweiligen Arbeitsverzeichnis
|
||||
unabhängig zu sein.
|
||||
|
||||
---
|
||||
|
||||
## 19. Statuszeile
|
||||
|
||||
Am unteren Rand des Hauptfensters ist permanent eine **Statuszeile** (`GuiStatusBar`)
|
||||
sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
|
||||
|
||||
| Position | Inhalt | Beispiel |
|
||||
|----------|--------|---------|
|
||||
| Links | Anwendungsversion | `V3.0.42` |
|
||||
| Mitte | Aktiver Provider und Modell | `Provider: Claude · claude-opus-4-7` |
|
||||
| Rechts | Pfad der geladenen Konfigurationsdatei | `config/application.properties` |
|
||||
|
||||
**Besonderheiten:**
|
||||
|
||||
- Die Versionsangabe wird aus der JAR-Manifest-Datei gelesen. Beim Start aus einer IDE
|
||||
ohne gepacktes JAR erscheint der Fallback `Vdev`.
|
||||
- Ist keine Konfiguration geladen, zeigen Mitte und Rechts den Text „Kein Profil geladen".
|
||||
- Die Statuszeile aktualisiert sich automatisch beim Laden, Speichern und Schließen
|
||||
einer Konfigurationsdatei.
|
||||
|
||||
---
|
||||
|
||||
## 20. Fehlerstatus – Bedeutung und Unterscheidung
|
||||
|
||||
Zwei Fehlerstatus werden in der GUI klar unterschieden. Die Unterscheidung ist wichtig,
|
||||
um zu entscheiden, ob eine erneute Verarbeitung sinnvoll ist.
|
||||
|
||||
### `↻` Wird wiederholt (orange) – `FAILED_RETRYABLE`
|
||||
|
||||
Das Dokument konnte vorübergehend nicht verarbeitet werden. Der Fehler ist
|
||||
wahrscheinlich technischer Natur und kann sich bei einem späteren Versuch
|
||||
von selbst auflösen.
|
||||
|
||||
**Typische Ursachen:** Netzwerkfehler, Timeout beim KI-Dienst, vorübergehende
|
||||
Nicht-Erreichbarkeit.
|
||||
|
||||
**Was passiert:** Das Dokument wird beim nächsten regulären Verarbeitungslauf
|
||||
**automatisch erneut versucht** – kein manuelles Eingreifen notwendig.
|
||||
|
||||
### `×` Fehlgeschlagen (rot) – `FAILED_FINAL`
|
||||
|
||||
Das Dokument ist dauerhaft nicht verarbeitbar. Automatische Wiederholversuche
|
||||
werden nicht mehr unternommen.
|
||||
|
||||
**Typische Ursachen:**
|
||||
- Kein lesbarer Text (z. B. eingescanntes Foto ohne OCR-Verarbeitung)
|
||||
- Passwortgeschützte PDF
|
||||
- Beschädigte oder unlesbare Datei
|
||||
|
||||
**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
|
||||
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
|
||||
der Eintrag vollständig gelöscht werden, damit die Datei als neu erkannt wird.
|
||||
|
||||
---
|
||||
|
||||
### Vollständige Status-Mapping-Tabelle
|
||||
|
||||
| Status | Icon | Farbe | Tooltip-Text | 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
|
||||
beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
|
||||
|
||||
---
|
||||
|
||||
## 21. Tooltips
|
||||
|
||||
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.
|
||||
|
||||
Tooltips sind unter anderem vorhanden auf:
|
||||
|
||||
- **Konfigurationsfeldern** – Quellordner, Zielordner, SQLite-Datei, Prompt-Datei,
|
||||
Provider-ComboBox, Modell-Feld, `max.text.characters`, `max.pages`, `max.title.length`
|
||||
- **Toolbar-Buttons** – Neu, Öffnen, Speichern, Speichern unter, Validieren,
|
||||
Technische Tests ausführen
|
||||
- **Status-Icons** im Verarbeitungslauf-Tab – Text gemäß Status-Mapping-Tabelle
|
||||
(Abschnitt 20)
|
||||
- **Buttons „Dateiname übernehmen"** und **„Zurücksetzen auf KI-Vorschlag"** im
|
||||
Dateiname-Editor (Abschnitt 13b)
|
||||
|
||||
Der Tooltip erscheint nach einer kurzen Verzögerung beim Verweilen mit der Maus
|
||||
über dem jeweiligen Element.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,924 @@
|
||||
# V3.1 – UX-Polish und Verlauf-Tab-Reife
|
||||
|
||||
**Status:** Zur Implementierung freigegeben
|
||||
**Erstellt:** 2026-05-05
|
||||
**Überarbeitet:** 2026-05-05 (nach ChatGPT-Review Runden 1, 2 und 3)
|
||||
**Autor:** Marcus (mit Claude als Mentor)
|
||||
|
||||
---
|
||||
|
||||
## Ziel
|
||||
|
||||
V3.1 ist der konsequente Nachschlag zu V3.0: Was der Produkttest aufgedeckt hat,
|
||||
wird hier bereinigt. Kein großes Architektur-Feature, kein neues Maven-Modul –
|
||||
**gezielter UX-Schliff und Robustheit**.
|
||||
|
||||
Schwerpunkte:
|
||||
|
||||
1. **Polieren** – sichtbare Schwächen aus dem V3.0-Produkttest beheben
|
||||
(#77, #80, #81, #83, #84, #88, #91)
|
||||
2. **Verlauf-Tab reifen lassen** – Suche, Mehrfachauswahl, DB-Neuanlage
|
||||
(#82, #86, #87)
|
||||
3. **Quick Win** – Mausrad-Zoom im PDF-Viewer als kleiner,
|
||||
wertvoller Gebrauchskomfort (#32)
|
||||
|
||||
Die fachliche Kernverarbeitung bleibt vollständig unverändert.
|
||||
|
||||
---
|
||||
|
||||
## Einordnung
|
||||
|
||||
V3.0 ist der abgeschlossene Ausgangspunkt. Hexagonale Architektur,
|
||||
Modulstruktur, headless-Betrieb, `.properties`-Konfigurationswahrheit
|
||||
und Flyway-DB-Evolution bleiben unangetastet.
|
||||
|
||||
V3.1 fügt **kein neues Maven-Modul** hinzu.
|
||||
|
||||
**Headless-Betrieb:** Der `adapter-in-cli`-Pfad erhält keine neue Bedienfunktion.
|
||||
Er ist jedoch von der globalen Lock-File-Pfadauflösung (#91) und einer
|
||||
ggf. notwendigen Flyway-Schemamigration (#88) betroffen – beide Änderungen
|
||||
wirken beim Programmstart, unabhängig von GUI oder CLI.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### In V3.1 enthalten
|
||||
|
||||
| # | Thema | Kategorie |
|
||||
|---|---|---|
|
||||
| #77 | Fehlende Tooltips | UX |
|
||||
| #80 | Dirty-Indikator für Konfigurations-Tab | UX |
|
||||
| #81 | Enum-Werte statt deutscher Bezeichnungen (Status-ComboBox + Versuche-Tabelle) | UX |
|
||||
| #82 | Verlauf-Tab: Live-Filter bei Suche | GUI |
|
||||
| #83 | KI-Begründung bei SUCCESS-Versuch verwirrend leer | UX |
|
||||
| #84 | Aktionsbuttons nach Laufende nicht sofort reaktiviert | Bug |
|
||||
| #86 | Mehrfachauswahl im Verlauf-Tab (Strg+A, Strg+Klick, Shift+Klick) | GUI |
|
||||
| #87 | Neue leere SQLite-Datenbank anlegen | GUI |
|
||||
| #88 | FAILED_FINAL-Einträge zeigen keine Fehlerursache im Verlauf-Tab | UX |
|
||||
| #91 | Lock-File relativer Pfad – Fallback wie Log-Verzeichnis | Robustheit |
|
||||
| #32 | Mausrad-Zoom in PDF-Vorschau | GUI |
|
||||
|
||||
### Explizit nicht in V3.1
|
||||
|
||||
- Automatischer Scheduler / Quellordner-Überwachung (#22) → V3.x
|
||||
- PDF-Viewer Render-DPI (#23) → V3.2
|
||||
- F1-Hilfe (#69) → V3.2
|
||||
- Dark Mode (#70) → V3.x
|
||||
- Log-Viewer in der GUI (#72) → V3.2
|
||||
- Token- und Kosten-Tracking (#74) → V3.2
|
||||
- Excel-Export (#75) → V3.2
|
||||
- Automatische Update-Prüfung (#76) → V3.2
|
||||
- Änderung der fachlichen Kernverarbeitung
|
||||
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
|
||||
|
||||
---
|
||||
|
||||
## Unverrückbare Leitplanken (unverändert gegenüber V3.0)
|
||||
|
||||
- Java 21, Maven Multi-Module, hexagonale Architektur
|
||||
- Shade-JAR als primäres Distributionsartefakt
|
||||
- GUI ist Standardstart, `--headless` bleibt vollständig erhalten
|
||||
- `.properties` bleibt die einzige Konfigurationswahrheit
|
||||
- Kein Webserver, kein Applikationsserver
|
||||
- GUI offiziell nur unter Windows; headless für Windows Server / Task Scheduler
|
||||
- JavaFX-Threading: I/O auf Worker-Thread, UI-Updates via `Platform.runLater()`
|
||||
- Kein JavaFX in Domain oder Application
|
||||
- JavaDoc-Standard für alle neuen öffentlichen Ports, Use-Cases, DTOs und Adapter-Methoden
|
||||
- Notwendige Code-Kommentare auf Deutsch; Logging auf Deutsch
|
||||
- Flyway ist die einzige Schema-Evolutionsquelle (kein manuelles DDL im Code)
|
||||
|
||||
---
|
||||
|
||||
## Status-Mapping-Tabelle (unverändert gegenüber V3.0)
|
||||
|
||||
Diese Tabelle ist weiterhin die einzige autoritative Quelle für Status-Darstellung
|
||||
in der GUI. Sie gilt verbindlich für alle V3.1-Features, die Statuswerte anzeigen –
|
||||
insbesondere #81 (Status-ComboBox, Versuche-Tabelle).
|
||||
|
||||
**Alle acht Statuswerte müssen vollständig unterstützt werden.**
|
||||
Kein Enum-Rohname darf für Endnutzer sichtbar sein.
|
||||
|
||||
| Domain-Status (`ProcessingStatus`) | GUI-Icon | Farbe | GUI-Text (Tooltip) | Summary-Kategorie |
|
||||
|---|---|---|---|---|
|
||||
| `SUCCESS` | `✓` | Grün | „Erfolgreich verarbeitet und umbenannt." | erfolgreich |
|
||||
| `FAILED_RETRYABLE` | `↻` | Orange | „Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht." | wird wiederholt |
|
||||
| `FAILED_FINAL` | `×` | Rot | „Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch." | fehlgeschlagen |
|
||||
| `SKIPPED_ALREADY_PROCESSED` | `≡` | Grau | „Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet." | übersprungen |
|
||||
| `SKIPPED_FINAL_FAILURE` | `⊘` | Dunkelgrau | „Endgültig übersprungen nach wiederholten Fehlern." | endgültig übersprungen |
|
||||
| `READY_FOR_AI` | `⟳` | Blau | „Wartet auf Verarbeitung." | – |
|
||||
| `PROPOSAL_READY` | `◇` | Hellblau | „KI-Vorschlag liegt vor, wartet auf Bestätigung." | – |
|
||||
| `PROCESSING` | `▶` | Hellgrau | „Wird gerade verarbeitet." | – |
|
||||
|
||||
**Wichtig:** Farbe ist niemals das einzige Unterscheidungsmerkmal.
|
||||
Icon und Tooltip-Text müssen den Status allein eindeutig beschreiben.
|
||||
|
||||
---
|
||||
|
||||
## UX-Polishing-Features
|
||||
|
||||
### #77 – Fehlende Tooltips
|
||||
|
||||
#### Problem
|
||||
|
||||
Der V3.0-Produkttest hat GUI-Elemente identifiziert, die noch keinen Tooltip
|
||||
tragen. Die Infrastruktur (`GuiTooltipTexts`, `setTooltip()`) existiert bereits
|
||||
aus #66 – es fehlt nur die konsequente Anwendung.
|
||||
|
||||
#### Lösung
|
||||
|
||||
Vor der Implementierung führt Claude Code eine **vollständige Bestandsaufnahme**
|
||||
durch: Alle interaktiven Elemente auf allen Tabs werden gegen vorhandene Tooltips
|
||||
geprüft. Maßgeblich ist die Bestandsaufnahme – die Zahl 16 stammt aus dem
|
||||
Produkttest und ist nicht bindend. Werden mehr fehlende Elemente gefunden,
|
||||
werden alle ergänzt.
|
||||
|
||||
Fehlende Tooltips werden in `GuiTooltipTexts` als Konstanten ergänzt und
|
||||
im jeweiligen GUI-Tab via `element.setTooltip(new Tooltip(GuiTooltipTexts.XY))`
|
||||
gesetzt. Keine hartcodierten Strings.
|
||||
|
||||
**Tooltips auf `TableColumn`-Headern (Sonderfall JavaFX):**
|
||||
|
||||
`TableColumn` ist kein normaler JavaFX-Node; `setTooltip()` ist darauf nicht
|
||||
direkt anwendbar. **Kein Skin-/Lookup-Hack.** Falls Header-Tooltips benötigt
|
||||
werden, wird ein `Label` als Column-Graphic gesetzt:
|
||||
|
||||
```java
|
||||
Label headerLabel = new Label("Spaltenname");
|
||||
headerLabel.setTooltip(new Tooltip("Erklärungstext"));
|
||||
column.setGraphic(headerLabel);
|
||||
column.setText("");
|
||||
```
|
||||
|
||||
Bei der Umsetzung muss geprüft werden, dass Sortierung, Header-Breite
|
||||
und bestehendes CSS durch das Column-Graphic-Pattern nicht sichtbar
|
||||
verschlechtert werden.
|
||||
|
||||
Falls das Projekt bereits eine stabile eigene Lösung für Column-Tooltips
|
||||
besitzt, wird diese wiederverwendet.
|
||||
|
||||
**Zu prüfende Tabs und Elemente (Anhaltspunkte):**
|
||||
|
||||
| Tab | Verdächtige Elemente |
|
||||
|---|---|
|
||||
| Verlauf | Tabellenspalten-Header, Suchfeld, Such-Button, Aktions-Buttons (Reset, Löschen) |
|
||||
| Verlauf (Detail) | Status-Icon, Versuche-Tabelle Spalten, KI-Begründung-Bereich |
|
||||
| Prompt | Speichern-Button, Zurücksetzen-Button, TextArea |
|
||||
| Allgemein | Fortschrittsbalken, Summary-Banner-Elemente |
|
||||
|
||||
**Technisch:** Ausschließlich `adapter-in-gui` und `GuiTooltipTexts`.
|
||||
Keine Architektur-Änderungen.
|
||||
|
||||
---
|
||||
|
||||
### #80 – Dirty-Indikator für Konfigurations-Tab
|
||||
|
||||
#### Problem
|
||||
|
||||
Der Prompt-Tab zeigt bereits einen `*`-Dirty-Indikator im Tab-Titel und warnt
|
||||
beim Verlassen mit ungespeicherten Änderungen. Der Konfigurations-Tab hat dieses
|
||||
Verhalten nicht – Nutzer verlieren versehentlich Änderungen.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Dirty-State-Tracking mit Baseline-Snapshot:**
|
||||
|
||||
Beim Laden einer Konfiguration wird ein **Baseline-Snapshot** des geladenen Zustands
|
||||
gespeichert. Dirty-State entsteht durch Vergleich des aktuellen Formularinhalts
|
||||
mit dem Snapshot – nicht durch blindes „erster Listener feuert".
|
||||
|
||||
Während programmgesteuertem Laden oder Normalisieren von Feldinhalten wird
|
||||
Dirty-Tracking temporär unterdrückt (Flag `loadingInProgress`), damit
|
||||
programmatische Feldänderungen keinen unechten Dirty-State auslösen.
|
||||
|
||||
- Beim ersten echten Nutzerwechsel gegenüber dem Snapshot: Tab-Titel wechselt
|
||||
auf `* Konfiguration`
|
||||
- Dirty-Flag wird zurückgesetzt bei: Speichern, Speichern unter,
|
||||
Laden einer neuen Konfiguration (nach Bestätigungsdialog)
|
||||
|
||||
**Bestätigungsdialog bei Navigation mit Dirty State:**
|
||||
|
||||
Beim Laden einer neuen Konfiguration oder beim Schließen der Anwendung
|
||||
mit ungespeicherten Konfig-Änderungen:
|
||||
> „Die Konfiguration enthält ungespeicherte Änderungen. Jetzt speichern?"
|
||||
> [Speichern] [Verwerfen] [Abbrechen]
|
||||
|
||||
**Kopplung mit #87 (Neue Datenbank):**
|
||||
|
||||
Legt der Nutzer über „Neue Datenbank anlegen..." eine neue DB-Datei an,
|
||||
wird der DB-Pfad im Konfigurationsmodell geändert und der Konfig-Tab
|
||||
in den Dirty-State versetzt. Der bestehende Bestätigungsdialog greift
|
||||
beim nächsten Schließen oder Ladevorgang.
|
||||
|
||||
**UX-Konsistenz mit Prompt-Tab:**
|
||||
|
||||
Die UX muss identisch zum Prompt-Tab sein: Sternchen im Tab-Titel,
|
||||
Warn-/Speicherdialog beim Verlassen, Rücksetzen nach Speichern.
|
||||
Die **technische Umsetzung** darf im Konfig-Tab über Baseline-Snapshot
|
||||
und `loadingInProgress` erfolgen, wenn die komplexere Formularlogik
|
||||
das erfordert.
|
||||
|
||||
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
|
||||
|
||||
---
|
||||
|
||||
### #81 – Enum-Werte statt deutscher Bezeichnungen
|
||||
|
||||
#### Problem
|
||||
|
||||
Die Status-ComboBox im Verlauf-Tab zeigt rohe Enum-Namen (`READY_FOR_AI`,
|
||||
`FAILED_FINAL` etc.). Die Versuche-Tabelle im Detailbereich zeigt ebenfalls
|
||||
Enum-Rohnamen in der Status-Spalte. Das ist für Endnutzer unlesbar.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Anzeige-Mapping:**
|
||||
|
||||
`ProcessingStatusPresentation` (existiert bereits aus #51) stellt die Mapping-Logik
|
||||
bereit. Dieses Mapping wird für alle Statusanzeigen im Verlauf-Tab verbindlich genutzt.
|
||||
**Alle acht Statuswerte der autoritativen Tabelle müssen abgedeckt sein:**
|
||||
|
||||
| Enum-Wert | Angezeigter Text |
|
||||
|---|---|
|
||||
| `SUCCESS` | „✓ Erfolgreich" |
|
||||
| `FAILED_RETRYABLE` | „↻ Temporärer Fehler" |
|
||||
| `FAILED_FINAL` | „× Dauerhaft fehlgeschlagen" |
|
||||
| `SKIPPED_ALREADY_PROCESSED` | „≡ Bereits verarbeitet" |
|
||||
| `SKIPPED_FINAL_FAILURE` | „⊘ Endgültig übersprungen" |
|
||||
| `READY_FOR_AI` | „⟳ Wartet auf Verarbeitung" |
|
||||
| `PROPOSAL_READY` | „◇ Vorschlag vorhanden" |
|
||||
| `PROCESSING` | „▶ In Bearbeitung" |
|
||||
|
||||
**Status-ComboBox:**
|
||||
|
||||
- Erster Eintrag: „Alle Status" – GUI-intern als `Optional.empty()` bzw. `null`-Filter
|
||||
behandelt; kein Domain-Enum-Wert
|
||||
- Weitere Einträge: alle acht Statuswerte mit Displaytext
|
||||
- Intern wird für DB-Queries stets der Enum-Name verwendet
|
||||
- `StringConverter<ProcessingStatus>` implementieren
|
||||
|
||||
**Versuche-Tabelle (Detailbereich):**
|
||||
|
||||
- Status-Spalte: `ProcessingStatusPresentation`-Mapping anwenden
|
||||
- Kein Enum-Rohname darf für Endnutzer sichtbar sein
|
||||
|
||||
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
|
||||
|
||||
---
|
||||
|
||||
### #83 – KI-Begründung bei SUCCESS-Versuch verwirrend leer
|
||||
|
||||
#### Problem
|
||||
|
||||
Im Detailbereich wird bei einem Versuch mit Status `SUCCESS` die
|
||||
KI-Begründungs-TextArea leer angezeigt. Nutzer verstehen nicht, ob das
|
||||
ein Fehler ist oder ob tatsächlich keine Begründung vorliegt.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Platzhalter über JavaFX `promptText` (kein echter Textinhalt):**
|
||||
|
||||
Bei leerem oder null `ai_reasoning` gilt:
|
||||
|
||||
```java
|
||||
textArea.setText("");
|
||||
textArea.setPromptText("Keine KI-Begründung für diesen Versuch gespeichert.");
|
||||
```
|
||||
|
||||
Der `promptText` wird von JavaFX automatisch gedimmt dargestellt und ist
|
||||
**nicht kopierbar, nicht speicherbar, nicht als Nutzdaten behandelbar**.
|
||||
Kein Vermischen von Daten und UI-Platzhaltertext.
|
||||
|
||||
Die TextArea bleibt sichtbar – ein leeres Feld ohne Erklärung ist schlechter
|
||||
als ein erklärender Platzhalter.
|
||||
|
||||
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case,
|
||||
keine DB-Änderung.
|
||||
|
||||
---
|
||||
|
||||
### #84 – Aktionsbuttons nach Laufende nicht sofort reaktiviert
|
||||
|
||||
#### Problem
|
||||
|
||||
Nach Abschluss eines Verarbeitungslaufs bleiben die Aktionsbuttons im Verlauf-Tab
|
||||
(„Status zurücksetzen", „Eintrag löschen") dauerhaft deaktiviert.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Ereignisgetriebene Button-State-Neuberechnung:**
|
||||
|
||||
Der Button-State wird nach jedem Lauf-Terminierungsereignis neu berechnet –
|
||||
unabhängig vom Grund der Terminierung:
|
||||
|
||||
- Erfolgreicher Laufabschluss
|
||||
- Fehlerabbruch (Exception im Worker)
|
||||
- Nutzerabbruch
|
||||
- Leerlauf (keine Dateien zu verarbeiten)
|
||||
|
||||
Nach Terminierung wird, sofern eine Auswahl in der Verlauf-Tabelle besteht,
|
||||
der zugehörige Aktionsbutton-State **ereignisgetrieben** aktiviert –
|
||||
ohne dass der Nutzer die Auswahl erneuern oder den Tab wechseln muss.
|
||||
|
||||
**Code-Analyse erforderlich:** Claude Code analysiert den genauen Signal-Pfad
|
||||
(Laufabschluss-Event → UI-Komponente) und korrigiert die fehlende
|
||||
`Platform.runLater()`-Kopplung.
|
||||
|
||||
**Technisch:** Vermutlich `adapter-in-gui` und ggf. `bootstrap` (Bridge-Verdrahtung).
|
||||
Kein neuer Port, kein Use-Case.
|
||||
|
||||
---
|
||||
|
||||
### #88 – FAILED_FINAL ohne Fehlerursache im Verlauf-Tab
|
||||
|
||||
#### Problem
|
||||
|
||||
Der Detailbereich zeigt bei `FAILED_FINAL`-, `FAILED_RETRYABLE`- und
|
||||
`SKIPPED_FINAL_FAILURE`-Einträgen keine Fehlerursache an.
|
||||
Der Nutzer sieht nur den Status-Icon.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Schema-/Code-Analyse als blockierender erster Schritt:**
|
||||
|
||||
Vor jeder weiteren Implementierung dokumentiert Claude Code verbindlich,
|
||||
welcher Fall vorliegt:
|
||||
|
||||
**Fall A – geeignetes Fehlerfeld bereits vorhanden:**
|
||||
`processing_attempt` enthält bereits ein nutzbares Fehlerfeld.
|
||||
→ Keine Migration. GUI und Abfrage werden um die Anzeige erweitert.
|
||||
|
||||
**Fall B – kein geeignetes Fehlerfeld vorhanden:**
|
||||
→ Flyway-Migration mit der **nächsten freien Versionsnummer** zum Zeitpunkt
|
||||
der Implementierung. Fehlerdetails können nur für ab V3.1 erzeugte
|
||||
Verarbeitungsversuche gespeichert werden. Bestehende Einträge bleiben
|
||||
unverändert und zeigen den Platzhalter „Keine Fehlerdetails gespeichert."
|
||||
|
||||
**Fall C – Fehlerdetails werden bisher nur im Log gespeichert:**
|
||||
→ Migration zwingend erforderlich. Zusätzlich muss der Fehlerpfad der
|
||||
Verarbeitungslogik um Persistierung der Fehlerdetails erweitert werden.
|
||||
|
||||
**Domain-Modul-Einschränkung:**
|
||||
|
||||
`pdf-umbenenner-domain` bleibt unverändert, sofern die benötigten
|
||||
Fehlerdetails ausschließlich über bestehende oder application-nahe
|
||||
History-DTOs transportiert werden können.
|
||||
|
||||
Falls das fachliche Attempt-Modell im Domain-Modul liegt und für die
|
||||
Anzeige erweitert werden muss, ist eine **minimale Domain-Erweiterung zulässig**.
|
||||
Keine Änderung an der fachlichen Kernverarbeitung.
|
||||
|
||||
**Datenmodell (bei Migration – Fall B oder C):**
|
||||
|
||||
```sql
|
||||
-- Versionsnummer = nächste freie Flyway-Version zum Zeitpunkt der Implementierung
|
||||
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
|
||||
```
|
||||
|
||||
`failure_details` enthält eine **nutzerverständliche, gekürzte Fehlerbeschreibung**.
|
||||
Provider- oder Exception-Meldungen werden **nicht roh persistiert** –
|
||||
gespeichert wird eine kontrolliert erzeugte Kurzmeldung aus bekannten
|
||||
Fehlerkategorien oder eine bereinigte/gekürzte Message ohne Stacktrace,
|
||||
API-Keys oder vollständige Provider-Rohantworten.
|
||||
|
||||
Die Begrenzung auf **1000 Zeichen wird spätestens vor Persistierung im
|
||||
DB-Adapter erzwungen**: Längere Texte werden gekürzt und mit „…" markiert.
|
||||
Falls bereits vorher ein zentrales Fehler-Mapping existiert, darf dort
|
||||
gekürzt werden. Entscheidend: in die DB gelangen nur gekürzte, bereinigte
|
||||
Fehlerdetails. Kein SQL-`CHECK`-Constraint (um Alt-/Importdaten nicht
|
||||
zu blockieren).
|
||||
|
||||
**„Letzter Versuch" – Definition:**
|
||||
|
||||
Die angezeigte Fehlerursache stammt aus dem Versuch mit dem höchsten
|
||||
`attempt_number`. Bei Gleichstand wird der mit dem jüngsten `ended_at` verwendet.
|
||||
|
||||
Die Sortierung wird im Rahmen der Code-Analyse gegen das vorhandene Schema
|
||||
verifiziert. Falls `attempt_number` oder `ended_at` nicht existieren, wird
|
||||
die technisch eindeutige Sortierung des Attempt-Verlaufs verwendet und
|
||||
in der Implementierungsnotiz dokumentiert.
|
||||
|
||||
**Anzuzeigende Status:**
|
||||
|
||||
Fehlerursache wird angezeigt bei:
|
||||
- `FAILED_FINAL`
|
||||
- `FAILED_RETRYABLE`
|
||||
- `SKIPPED_FINAL_FAILURE` (zeigt die letzte bekannte Fehlerursache des
|
||||
zugrundeliegenden fehlgeschlagenen Attempts – fachlich konsistent,
|
||||
da `SKIPPED_FINAL_FAILURE` direkte Folge eines endgültigen Fehlschlags ist)
|
||||
|
||||
Bei fehlendem `failure_details` (NULL oder leer): Platzhaltertext via `promptText`
|
||||
analog zu #83.
|
||||
|
||||
**Technisch:** `adapter-in-gui` (Anzeige), ggf. `adapter-out-db`
|
||||
(Abfrage-Erweiterung), ggf. Flyway-Migration, ggf. minimale Domain-Erweiterung.
|
||||
|
||||
---
|
||||
|
||||
### #91 – Lock-File relativer Pfad
|
||||
|
||||
#### Problem
|
||||
|
||||
Der Lock-Mechanismus nutzt einen konfigurierten oder Standard-Pfad für die
|
||||
Lock-Datei. Bei relativem Pfad ist das Verzeichnis abhängig vom aktuellen
|
||||
Arbeitsverzeichnis. Liegt die JAR unter `C:\Program Files`, ist das Verzeichnis
|
||||
zudem nicht beschreibbar.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Verhalten abhängig vom Pfadtyp:**
|
||||
|
||||
**Absolut konfigurierter Pfad:**
|
||||
Wird unverändert verwendet. Schlägt das Anlegen fehl, erfolgt **kein Fallback** –
|
||||
der Nutzer hat den Speicherort explizit vorgegeben. Start bricht mit klarer
|
||||
Fehlermeldung ab.
|
||||
|
||||
**Relativer oder nicht konfigurierter (Default-)Pfad – zweistufige Fallback-Strategie:**
|
||||
|
||||
1. **Primär:** Auflösung relativ zum Verzeichnis der JAR-Datei
|
||||
(`CodeSource.getLocation()`)
|
||||
2. **Fallback:** Auflösung relativ zu `user.home`
|
||||
3. **Abbruch:** Erst wenn auch `user.home` fehlschlägt
|
||||
|
||||
**Parent-Verzeichnisse** werden bei Bedarf automatisch angelegt
|
||||
(`Files.createDirectories()`).
|
||||
|
||||
Der final verwendete **absolute Pfad wird beim Start geloggt** (INFO-Level):
|
||||
```
|
||||
Lock-Datei: C:\Users\Funny\Documents\pdf-umbenenner.lock
|
||||
```
|
||||
|
||||
**Gilt für GUI- und Headless-Start.**
|
||||
|
||||
**Code-Analyse erforderlich:** Claude Code ermittelt die aktuelle
|
||||
Lock-Implementierungslokation (`bootstrap` oder `adapter-out-db`).
|
||||
|
||||
---
|
||||
|
||||
## GUI-Features
|
||||
|
||||
### #82 – Verlauf-Tab: Live-Filter bei Suche
|
||||
|
||||
#### Problem
|
||||
|
||||
Die Suche im Verlauf-Tab wird nur durch expliziten Klick auf den Such-Button
|
||||
ausgelöst. Das erfordert unnötige Interaktion bei jeder Suchanpassung.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Live-Filter mit Debounce und Generation-Counter:**
|
||||
|
||||
- Das Suchfeld erhält einen `ChangeListener` auf die `textProperty()`
|
||||
- Bei jeder Texteingabe startet ein JavaFX-`Timeline`-Debounce-Timer (300 ms)
|
||||
- Nach 300 ms ohne weitere Eingabe wird die DB-Abfrage auf einem Worker-Thread gestartet
|
||||
|
||||
**Race-Condition-Schutz via Generation-Counter:**
|
||||
|
||||
Jede gestartete Suchanfrage erhält eine aufsteigende Generations-ID (atomarer
|
||||
`long`-Counter). Der Worker-Thread trägt seine Generations-ID ins Ergebnis.
|
||||
Beim `Platform.runLater()`-Callback wird das Ergebnis nur in die UI übernommen,
|
||||
wenn die Generations-ID noch aktuell ist – veraltete Worker-Ergebnisse
|
||||
werden verworfen.
|
||||
|
||||
**Such-Button und Enter-Taste:**
|
||||
|
||||
- Klick auf Such-Button oder Enter im Suchfeld: Debounce-Timer sofort abgebrochen,
|
||||
Suche unverzüglich gestartet
|
||||
- Barrierefreiheit: Such-Button bleibt erhalten
|
||||
|
||||
**Auswahlverhalten nach neuen Suchergebnissen:**
|
||||
|
||||
Nach jeder Übernahme neuer Suchergebnisse wird die Tabellenauswahl
|
||||
**vollständig geleert**. Detailbereich und Aktionsbuttons werden entsprechend
|
||||
zurückgesetzt. Das ist robuster als ein Abgleich der alten Auswahl gegen
|
||||
die neue Ergebnisliste und vermeidet Wechselwirkungen mit #86.
|
||||
|
||||
**Leeres Suchfeld:** Zeigt alle Einträge (bis LIMIT 501).
|
||||
|
||||
**Technisch:** Ausschließlich `adapter-in-gui`. Die bestehende Suchabfrage via
|
||||
`GuiHistoryOverviewPort` wird unverändert wiederverwendet.
|
||||
|
||||
---
|
||||
|
||||
### #86 – Mehrfachauswahl im Verlauf-Tab
|
||||
|
||||
#### Problem
|
||||
|
||||
Der Verlauf-Tab erlaubt nur Einzelauswahl. Bulk-Operationen sind nicht möglich.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Multi-Select-Modus:**
|
||||
|
||||
```java
|
||||
tableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
```
|
||||
|
||||
JavaFX stellt damit Strg+Klick und Shift+Klick automatisch bereit.
|
||||
|
||||
**Strg+A – Fokusabhängig:**
|
||||
|
||||
Strg+A selektiert alle sichtbaren Tabelleneinträge **nur, wenn die Verlauf-Tabelle
|
||||
den Fokus besitzt**. Liegt der Fokus im Suchfeld, bleibt Strg+A die normale
|
||||
Textauswahl im Suchfeld.
|
||||
|
||||
**Detailbereich bei Mehrfachauswahl:**
|
||||
|
||||
- Genau 1 Eintrag: Detailbereich wie bisher
|
||||
- Mehrere Einträge: Platzhaltertext „X Einträge ausgewählt."
|
||||
|
||||
**Snapshot der fachlichen Schlüssel vor Worker-Thread-Start:**
|
||||
|
||||
Vor dem Start einer Bulk-Operation wird ein **unveränderlicher Snapshot der
|
||||
fachlichen Schlüssel** erstellt, die die bestehenden Reset-/Delete-Use-Cases
|
||||
erwarten (typischerweise Fingerprints, sofern das die vorhandene Use-Case-Signatur
|
||||
erwartet). Der Worker-Thread arbeitet ausschließlich auf diesem Snapshot –
|
||||
nie auf einer Live-`ObservableList`, die sich während der Operation ändern könnte.
|
||||
|
||||
**Aktionsbuttons bei Mehrfachauswahl:**
|
||||
|
||||
| Aktion | Verhalten |
|
||||
|---|---|
|
||||
| „Status zurücksetzen" | Aktiv bei ≥ 1 Auswahl; Bestätigungsdialog: „X Einträge zurücksetzen?" |
|
||||
| „Eintrag löschen" | Aktiv bei ≥ 1 Auswahl; Bestätigungsdialog: „X Einträge unwiderruflich löschen?" |
|
||||
|
||||
**Bulk-Fehlerstrategie (Partial Success):**
|
||||
|
||||
Schlägt eine Operation bei einzelnen Einträgen fehl, werden die restlichen
|
||||
trotzdem abgearbeitet. Nach Abschluss erscheint ein **kompakter**
|
||||
Zusammenfassungsdialog:
|
||||
> „X von Y Einträgen erfolgreich verarbeitet. Z Einträge konnten nicht
|
||||
> verarbeitet werden."
|
||||
|
||||
Detaillierte Einzelfehler werden geloggt, nicht in den Dialog gestopft.
|
||||
|
||||
**Ausführung:** Bulk-Operationen rufen die bestehenden Use-Cases
|
||||
(`DefaultResetDocumentStatusUseCase`, `DefaultDeleteDocumentHistoryUseCase`)
|
||||
sequenziell auf dem Worker-Thread auf. Keine neuen Use-Cases erforderlich.
|
||||
|
||||
**Sperren während Lauf:** Alle Aktions-Buttons deaktiviert während eines
|
||||
aktiven Verarbeitungslaufs.
|
||||
|
||||
**Technisch:** Ausschließlich `adapter-in-gui`. Keine neuen Ports oder Use-Cases.
|
||||
|
||||
---
|
||||
|
||||
### #87 – Neue leere SQLite-Datenbank anlegen
|
||||
|
||||
#### Problem
|
||||
|
||||
Will der Nutzer mit einer frischen Datenbank starten, muss er die Datei
|
||||
manuell löschen. Das ist umständlich und fehleranfällig.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Neuer Menüpunkt:**
|
||||
|
||||
`Datenbank → Neue Datenbank anlegen...`
|
||||
|
||||
(Nur aktiv wenn kein Verarbeitungslauf läuft.)
|
||||
|
||||
**Eigentümer des aktiven Datenbankkontexts:**
|
||||
|
||||
Der Runtime-Wechsel der aktiven Datenbank erfordert eine zentrale Komponente,
|
||||
die den aktiven Datenbankkontext besitzt. Vor der Implementierung analysiert
|
||||
Claude Code, ob eine solche Komponente bereits existiert.
|
||||
|
||||
- **Fall A – wechselbarer DB-Kontext vorhanden:** Vorhandene Komponente
|
||||
wird genutzt/erweitert.
|
||||
- **Fall B – kein wechselbarer DB-Kontext vorhanden:** Es wird ein minimaler
|
||||
`ActiveDatabaseContextPort` eingeführt (Outbound-Port in `application`,
|
||||
Adapter in `bootstrap` oder `adapter-out-db`). Dieser Port ist die einzige
|
||||
Stelle, an der die aktive DB-Referenz umgestellt wird.
|
||||
|
||||
**Der DB-Wechsel darf nicht im JavaFX-Code versteckt werden.**
|
||||
Der Use-Case `DefaultCreateNewDatabaseUseCase` orchestriert den Wechsel;
|
||||
die physische Umstellung der Verbindung delegiert er über den Port.
|
||||
|
||||
**Ablauf (atomar aus Anwendungssicht):**
|
||||
|
||||
1. `FileChooser` öffnet (Filter: `*.sqlite`); Nutzer wählt Zieldatei
|
||||
2. **Pfad-Sicherheitsprüfung:**
|
||||
Die aktive DB und die gewählte Zieldatei werden über **normalisierte,
|
||||
absolut aufgelöste Pfade** verglichen – kein Rohstring-Vergleich.
|
||||
Für existierende Dateien wird `toRealPath()` verwendet; für noch nicht
|
||||
existierende Dateien wird der Parent-Pfad real aufgelöst und der Dateiname
|
||||
normalisiert verglichen. Unter Windows erfolgt der Vergleich case-insensitive.
|
||||
Bei Übereinstimmung: klare Fehlermeldung, kein Überschreiben.
|
||||
3. Existiert die Zieldatei (andere als aktive DB): Bestätigungsdialog
|
||||
„Die Datei existiert bereits. Überschreiben?"
|
||||
4. **GUI-Sperre:** Während Anlage und Wechsel befindet sich die GUI in einem
|
||||
`DB-Busy`-Zustand. Alle DB-lesenden und DB-schreibenden Aktionen
|
||||
(Live-Suche, Bulk-Reset, Bulk-Delete, Verlauf-Refresh, erneuter
|
||||
Klick auf „Neue Datenbank anlegen") sind deaktiviert. Der Zustand
|
||||
wird nach Erfolg oder Fehler zuverlässig zurückgesetzt.
|
||||
5. Neue SQLite-Datei wird als **temporäre Datei im Zielverzeichnis** erzeugt
|
||||
6. Flyway führt alle verfügbaren Migrationsskripte gegen die temporäre Datei aus
|
||||
(`migrate()` auf neuesten Schema-Stand)
|
||||
7. Neue DB-Verbindung wird **testweise geöffnet und geprüft** (gegen Temp-Datei).
|
||||
Der Verbindungstest prüft mindestens:
|
||||
- SQLite-Verbindung kann geöffnet werden
|
||||
- Flyway-Schema-History ist vorhanden
|
||||
- Eine einfache Leseabfrage gegen Schema-Metadaten ist erfolgreich
|
||||
8. Erst nach erfolgreichem Test: temporäre Datei zur Zieldatei verschoben.
|
||||
Bei bereits existierender, bestätigter Zieldatei wird
|
||||
`Files.move(tempFile, targetFile, ATOMIC_MOVE, REPLACE_EXISTING)` verwendet,
|
||||
sofern vom Dateisystem unterstützt. Die vorhandene Zieldatei wird vorher
|
||||
**nicht separat gelöscht**. Wird die Kombination `ATOMIC_MOVE + REPLACE_EXISTING`
|
||||
nicht unterstützt, bricht der Vorgang mit klarer Fehlermeldung ab –
|
||||
kein unsicherer halb-atomarer Fallback.
|
||||
9. Aktive DB-Referenz der Anwendung umgestellt (via `ActiveDatabaseContextPort`)
|
||||
10. Verlauf-Tab neu geladen → zeigt „Noch keine Verarbeitungen vorhanden."
|
||||
11. Statuszeile aktualisiert DB-Pfad
|
||||
12. DB-Pfad im Konfigurationsmodell geändert → Konfig-Tab wechselt in Dirty-State
|
||||
13. Statuszeile oder Meldungsbereich zeigt:
|
||||
„Neue Datenbank ist aktiv. Konfiguration speichern, damit diese DB
|
||||
beim nächsten Start verwendet wird."
|
||||
|
||||
**Fehlerfall ohne partielle Änderung:**
|
||||
|
||||
Schlägt ein Schritt (Anlegen, Flyway, Verbindungstest, Move) fehl, bleibt die
|
||||
bisher aktive DB **vollständig unverändert in Betrieb**. Die temporäre Datei
|
||||
wird gelöscht. Fehlerdialog mit konkreter Meldung.
|
||||
|
||||
**Headless:** Die Funktion ist ausschließlich GUI-seitig aufrufbar.
|
||||
`adapter-in-cli` ist nicht betroffen.
|
||||
|
||||
**Architektur:**
|
||||
|
||||
| Komponente | Typ | Modul | Zweck |
|
||||
|---|---|---|---|
|
||||
| `CreateNewDatabaseUseCase` | Inbound-Port-Interface | `application` | Vertrag: `createNewDatabase(Path)` |
|
||||
| `DefaultCreateNewDatabaseUseCase` | Use-Case-Impl. | `application` | Atomarer DB-Wechsel: Temp-Datei, Flyway, Test, Move, Kontext-Umstellung |
|
||||
| `DatabaseCreationPort` | Outbound-Port | `application` | `createAndInitialize(Path tempFile)` |
|
||||
| `ActiveDatabaseContextPort` | Outbound-Port | `application` | `switchActiveDatabase(Path newDbFile)` – Eigentümer des Laufzeitkontexts |
|
||||
| `GuiCreateNewDatabasePort` | Bridge-Interface | `adapter-in-gui` | Brücke zum Use-Case |
|
||||
| `SqliteDatabaseCreationAdapter` | Outbound-Adapter | `adapter-out-db` | SQLite-Temp-Datei erzeugen, Flyway migrate auf latest, Verbindung testen |
|
||||
| `SqliteActiveDatabaseContextAdapter` | Outbound-Adapter | `bootstrap` oder `adapter-out-db` | Umschalten der aktiven DB-Referenz (Analyse erforderlich) |
|
||||
|
||||
---
|
||||
|
||||
### #32 – Mausrad-Zoom in PDF-Vorschau
|
||||
|
||||
#### Problem
|
||||
|
||||
Die PDF-Vorschau lässt sich nur über die Zoom-Buttons skalieren.
|
||||
Ein Mausrad-Zoom fehlt.
|
||||
|
||||
#### Lösung
|
||||
|
||||
**Scroll-Event auf der PDF-Vorschau-Komponente:**
|
||||
|
||||
```java
|
||||
scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
|
||||
if (event.isControlDown()) {
|
||||
accumulateAndApplyZoomDelta(event.getDeltaY());
|
||||
event.consume(); // immer konsumieren bei Strg, kein paralleles Scrollen
|
||||
}
|
||||
// ohne Strg: normales Scrollen bleibt
|
||||
});
|
||||
```
|
||||
|
||||
**Bei gedrückter Strg-Taste werden ScrollEvents grundsätzlich konsumiert**,
|
||||
damit kein paralleles Scrollen im ScrollPane erfolgt – auch wenn der Delta
|
||||
zu klein für einen Zoomschritt ist.
|
||||
|
||||
**Delta-Akkumulation für Trackpad-Kompatibilität:**
|
||||
|
||||
Sehr kleine Trackpad-Deltas werden **intern akkumuliert**, bis die Mindestschwelle
|
||||
für einen Zoomschritt erreicht ist. Kein Verwerfen: akkumulierte Deltas
|
||||
ergeben bei genug Trackpad-Wischbewegung sauber einen Zoomschritt.
|
||||
Als Orientierungswert gilt ±10 % je „Notch" eines Standard-Mausrads.
|
||||
|
||||
**Zoom-Verhalten:**
|
||||
|
||||
| Parameter | Wert |
|
||||
|---|---|
|
||||
| Auslöser | Strg + Mausrad |
|
||||
| Schrittweite | Vorzeichenbasiert auf akkumuliertem `deltaY`, ca. 10 % je Notch |
|
||||
| Minimum | 10 % |
|
||||
| Maximum | 500 % |
|
||||
| Zurücksetzen bei neuem PDF | Ja (Zoom auf Fit-to-Width) |
|
||||
|
||||
**Fit-to-Width-Modus:**
|
||||
|
||||
Nach manuellem Strg+Mausrad-Zoom verlässt die Vorschau den Fit-to-Width-Modus.
|
||||
Fit-to-Width wird erst wieder aktiv, wenn ein neues PDF geladen oder der
|
||||
Fit-to-Width-Button explizit erneut betätigt wird.
|
||||
|
||||
**Viewport-Stabilität:**
|
||||
|
||||
Beim Zoom bleibt die sichtbare Viewport-Mitte möglichst erhalten.
|
||||
|
||||
**Zoom-State-Konsistenz:**
|
||||
|
||||
Der Zoom-State wird über dieselbe Variable geführt, die auch die
|
||||
Toolbar-Zoom-Buttons bedienen.
|
||||
|
||||
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Zusammenfassung
|
||||
|
||||
### Neue Inbound-Port-Interfaces und Use-Cases
|
||||
|
||||
| Komponente | Typ | Modul | Zweck | Issue |
|
||||
|---|---|---|---|---|
|
||||
| `CreateNewDatabaseUseCase` | Inbound-Port-Interface | `application` | Vertrag für DB-Anlage | #87 |
|
||||
| `DefaultCreateNewDatabaseUseCase` | Use-Case-Impl. | `application` | Atomarer DB-Wechsel via Temp-Datei + Port-Delegation | #87 |
|
||||
|
||||
### Neue Outbound-Ports
|
||||
|
||||
| Komponente | Modul | Zweck | Issue |
|
||||
|---|---|---|---|
|
||||
| `DatabaseCreationPort` | `application` | Temp-Datei erzeugen, Flyway, Verbindungstest | #87 |
|
||||
| `ActiveDatabaseContextPort` | `application` | `switchActiveDatabase(Path)` – Laufzeit-DB-Kontext | #87 |
|
||||
|
||||
### Neue Bridge-Interfaces (adapter-in-gui)
|
||||
|
||||
| Interface | Zweck | Issue |
|
||||
|---|---|---|
|
||||
| `GuiCreateNewDatabasePort` | Brücke zur DB-Anlage | #87 |
|
||||
|
||||
### Neue Adapter
|
||||
|
||||
| Adapter | Modul | Zweck | Issue |
|
||||
|---|---|---|---|
|
||||
| `SqliteDatabaseCreationAdapter` | `adapter-out-db` | SQLite-Temp-Datei, Flyway migrate auf latest, Test | #87 |
|
||||
| `SqliteActiveDatabaseContextAdapter` | `bootstrap` oder `adapter-out-db` | Umschalten der aktiven DB-Referenz (Lokation via Code-Analyse) | #87 |
|
||||
|
||||
### Geänderte Komponenten (adapter-in-gui)
|
||||
|
||||
| Komponente | Änderung | Issues |
|
||||
|---|---|---|
|
||||
| `GuiHistoryTab` | Multi-Select + Schlüssel-Snapshot, Live-Filter + Generation-Counter + Auswahl leeren, Fehlerursache, Platzhalter via promptText, Tooltips, DB-Busy-Sperre | #82, #83, #86, #88, #77, #87 |
|
||||
| `GuiConfigTab` | Dirty-State mit Baseline-Snapshot + loadingInProgress, Tab-Titel, Dialog, Kopplung mit #87 | #80 |
|
||||
| `GuiTooltipTexts` | Neue Tooltip-Konstanten; TableColumn-Header via Column-Graphic-Pattern | #77 |
|
||||
| Verlauf-Detailbereich | Enum-Displaytext (alle 8 Werte), Fehlerursache für FAILED/SKIPPED_FINAL | #81, #88 |
|
||||
| Status-ComboBox | `StringConverter<ProcessingStatus>`, „Alle Status" als GUI-interner Null-Filter | #81 |
|
||||
| PDF-Vorschau-Komponente | Delta-Akkumulation, Strg+Scroll konsumiert, Viewport-Stabilität, Fit-to-Width-Modus | #32 |
|
||||
| Lauf-Abschluss-Signalkette | Ereignisgetriebene Button-State-Neuberechnung für alle Terminierungsgründe | #84 |
|
||||
|
||||
### Geänderte Komponenten (sonstige)
|
||||
|
||||
| Komponente | Modul | Änderung | Issue |
|
||||
|---|---|---|---|
|
||||
| Lock-File-Auflösung | `bootstrap` oder `adapter-out-db` | Absolut: direkt + Abbruch; Relativ: JAR-Dir → user.home → Abbruch; Parent-Dirs; Logging | #91 |
|
||||
|
||||
### Nicht geändert
|
||||
|
||||
- `pdf-umbenenner-domain` – keine Änderungen, außer ggf. minimale Erweiterung
|
||||
für #88 falls Attempt-Modell dort liegt (zulässig, keine Kernverarbeitungslogik)
|
||||
- `pdf-umbenenner-adapter-in-cli` – keine neuen Funktionen
|
||||
- Headless-Verarbeitungslogik – vollständig unberührt
|
||||
- Kernverarbeitungslogik (PDF lesen → KI → umbenennen)
|
||||
|
||||
---
|
||||
|
||||
## Datenbankmigrationen
|
||||
|
||||
Flyway ist die einzige Schema-Evolutionsquelle.
|
||||
|
||||
### Potenzielles Migrationsskript (abhängig von Code-Analyse #88)
|
||||
|
||||
Vor der Implementierung von #88 dokumentiert Claude Code verbindlich,
|
||||
ob ein Fehlerfeld bereits im Schema existiert (Fall A / B / C – siehe #88).
|
||||
|
||||
**Nur bei Fall B oder C:**
|
||||
|
||||
```sql
|
||||
-- Fehlerdetails in processing_attempt ergänzen
|
||||
-- Versionsnummer = nächste freie Flyway-Version zum Zeitpunkt der Implementierung
|
||||
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
|
||||
```
|
||||
|
||||
- `failure_details`: nutzerverständliche, gekürzte Fehlerbeschreibung;
|
||||
Begrenzung auf 1000 Zeichen **vor Persistierung im Adapter** erzwungen,
|
||||
Kürzung mit „…"; kein SQL-`CHECK`-Constraint
|
||||
- Bestehende Zeilen erhalten automatisch `NULL` – kein Datenverlust
|
||||
- Alte Einträge ohne Fehlerdetails zeigen `promptText`-Platzhalter in der GUI
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done (V3.1 gesamt)
|
||||
|
||||
- [ ] Alle 11 Issues implementiert und einzeln getestet
|
||||
- [ ] `mvn clean verify` grün (alle Module, kein `-DskipTests`)
|
||||
- [ ] `mvn clean install -Drevision=3.1.0` – Build ohne Fehler
|
||||
- [ ] Manueller GUI-Produkttest durchgeführt (Green build ≠ fertige Software)
|
||||
- [ ] Keine Enum-Rohnamen in der GUI sichtbar (alle 8 Statuswerte mit Displaytext)
|
||||
- [ ] Alle fehlenden Tooltips vorhanden; TableColumn-Header via Column-Graphic-Pattern
|
||||
- [ ] Dirty-Indikator Konfig-Tab: kein programmgesteuertes Feuern, Baseline-Snapshot korrekt
|
||||
- [ ] Live-Filter: 300 ms Debounce, Generation-Counter, Auswahl nach Suche geleert
|
||||
- [ ] Mehrfachauswahl: Strg+A nur bei Tabellenfokus; Schlüssel-Snapshot; Partial-Success-Dialog
|
||||
- [ ] `FAILED_FINAL`/`FAILED_RETRYABLE`/`SKIPPED_FINAL_FAILURE`: Fehlerursache sichtbar (oder Platzhalter)
|
||||
- [ ] Leere `ai_reasoning`: `promptText`-Platzhalter (kein echter Text)
|
||||
- [ ] Aktionsbuttons ereignisgetrieben reaktiviert nach allen Terminierungsgründen
|
||||
- [ ] #87 Code-Analyse: DB-Kontext-Eigentümer dokumentiert (Fall A oder B)
|
||||
- [ ] #87: Atomarer Ablauf via Temp-Datei; Pfadvergleich normalisiert + case-insensitive
|
||||
- [ ] #87: Aktive DB bleibt bei Fehler unverändert; DB-Busy-Sperre korrekt zurückgesetzt
|
||||
- [ ] #87: Flyway auf neuesten Stand; Hinweismeldung nach Wechsel
|
||||
- [ ] Strg+Mausrad-Zoom: Delta-Akkumulation, immer konsumiert bei Strg, 10%–500%
|
||||
- [ ] Lock-File: Absolut direkt; Relativ zweistufig; Parent-Dirs; Pfad geloggt
|
||||
- [ ] Code-Kommentare auf Deutsch; Logging auf Deutsch
|
||||
- [ ] JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
|
||||
- [ ] `betrieb.md` und `gui-bedienanleitung.md` auf V3.1-Stand gebracht
|
||||
- [ ] Freigabedokument `freigabe-v3_1.md` erstellt
|
||||
|
||||
---
|
||||
|
||||
## Abnahmekriterien je Feature
|
||||
|
||||
### #77 Fehlende Tooltips
|
||||
- [ ] Vollständige Bestandsaufnahme: Liste aller Elemente ohne Tooltip erstellt
|
||||
- [ ] Alle identifizierten Elemente haben Tooltips (Anzahl aus Bestandsaufnahme)
|
||||
- [ ] TableColumn-Header: Column-Graphic mit Label+Tooltip, kein Skin-/Lookup-Hack
|
||||
- [ ] Column-Graphic: Sortierung, Header-Breite und CSS nicht sichtbar verschlechtert
|
||||
- [ ] Neue Konstanten ausschließlich in `GuiTooltipTexts`, keine hartcodierten Strings
|
||||
|
||||
### #80 Dirty-Indikator Konfig-Tab
|
||||
- [ ] Tab-Titel `* Konfiguration` nur nach echter Nutzeränderung gegenüber Baseline-Snapshot
|
||||
- [ ] Programmgesteuertes Laden setzt kein Dirty-Flag (`loadingInProgress`-Schutz)
|
||||
- [ ] Tab-Titel `Konfiguration` nach Speichern
|
||||
- [ ] Bestätigungsdialog bei Laden neuer Konfig mit Dirty State
|
||||
- [ ] DB-Pfad-Wechsel via #87 setzt Konfig-Tab dirty
|
||||
- [ ] UX identisch zum Prompt-Tab (Sternchen, Dialog, Reset)
|
||||
|
||||
### #81 Enum-Bezeichnungen
|
||||
- [ ] Status-ComboBox: „Alle Status" als erster Eintrag (GUI-interner Null-Filter)
|
||||
- [ ] Status-ComboBox: alle 8 Statuswerte als Displaytext
|
||||
- [ ] Versuche-Tabelle: alle 8 Statuswerte als Displaytext
|
||||
- [ ] DB-Queries intern weiterhin mit Enum-Namen
|
||||
- [ ] Kein Enum-Rohname für Endnutzer sichtbar
|
||||
|
||||
### #82 Live-Filter
|
||||
- [ ] Suche startet nach 300 ms Tipp-Pause automatisch
|
||||
- [ ] Generation-Counter: veraltete Worker-Ergebnisse werden verworfen
|
||||
- [ ] Such-Button / Enter: sofortige Suche, Debounce abgebrochen
|
||||
- [ ] Auswahl nach neuen Suchergebnissen vollständig geleert
|
||||
- [ ] Leeres Suchfeld zeigt alle Einträge
|
||||
- [ ] Worker-Thread, UI via `Platform.runLater()`
|
||||
|
||||
### #83 KI-Begründung leer
|
||||
- [ ] `textArea.setPromptText(...)` bei leerem/null `ai_reasoning`
|
||||
- [ ] `textArea.setText("")` – kein Platzhaltertext als echter Inhalt
|
||||
- [ ] TextArea bleibt sichtbar
|
||||
|
||||
### #84 Buttons reaktivieren
|
||||
- [ ] Aktionsbuttons während Lauf deaktiviert
|
||||
- [ ] Reaktivierung ereignisgetrieben nach: Erfolg, Fehlerabbruch, Nutzerabbruch, Exception
|
||||
- [ ] Keine manuellen Workarounds notwendig
|
||||
|
||||
### #86 Mehrfachauswahl
|
||||
- [ ] `SelectionMode.MULTIPLE` aktiv
|
||||
- [ ] Strg+A nur bei Tabellenfokus (kein Konflikt mit Suchfeld)
|
||||
- [ ] Strg+Klick, Shift+Klick korrekt
|
||||
- [ ] Detailbereich: „X Einträge ausgewählt." bei Mehrfachauswahl
|
||||
- [ ] Schlüssel-Snapshot vor Worker-Thread-Start
|
||||
- [ ] Bulk-Reset: Bestätigungsdialog + Partial-Success-Dialog
|
||||
- [ ] Bulk-Delete: Bestätigungsdialog + Partial-Success-Dialog
|
||||
- [ ] Aktionen während Lauf gesperrt
|
||||
|
||||
### #87 Neue Datenbank anlegen
|
||||
- [ ] Code-Analyse: DB-Kontext-Eigentümer dokumentiert, Fall A oder B entschieden
|
||||
- [ ] Menüpunkt vorhanden, nur außerhalb von Läufen aktiv
|
||||
- [ ] Aktive DB über normalisierten Pfadvergleich (case-insensitive, toRealPath) erkannt
|
||||
- [ ] Bestehende Fremddatei: Überschreiben-Bestätigung
|
||||
- [ ] DB-Busy-Sperre während Anlage aktiv; nach Erfolg/Fehler zuverlässig zurückgesetzt
|
||||
- [ ] Neue DB als Temp-Datei; Flyway auf neuesten Stand
|
||||
- [ ] Verbindungstest: Verbindung öffnen, Flyway-History prüfen, Leseabfrage erfolgreich
|
||||
- [ ] Move mit `ATOMIC_MOVE + REPLACE_EXISTING`; vorhandene Datei nicht vorher separat löschen
|
||||
- [ ] Kein halb-atomarer Fallback bei nicht unterstützter Kombination
|
||||
- [ ] Fehlerfall: Temp-Datei gelöscht, aktive DB unverändert, Fehlerdialog
|
||||
- [ ] `ActiveDatabaseContextPort.switchActiveDatabase()` schaltet Referenz um
|
||||
- [ ] Verlauf-Tab: „Noch keine Verarbeitungen vorhanden."
|
||||
- [ ] Statuszeile aktualisiert DB-Pfad
|
||||
- [ ] Konfig-Tab wechselt in Dirty-State
|
||||
- [ ] Hinweismeldung: Konfiguration speichern nicht vergessen
|
||||
|
||||
### #88 Fehlerursache FAILED_FINAL
|
||||
- [ ] Schema-/Code-Analyse: Fall A/B/C dokumentiert vor Implementierung
|
||||
- [ ] Ggf. Flyway-Migration mit nächster freier Versionsnummer
|
||||
- [ ] Sortierung für „letzter Versuch" gegen Schema verifiziert
|
||||
- [ ] Detailbereich: `failure_details` bei `FAILED_FINAL`, `FAILED_RETRYABLE`, `SKIPPED_FINAL_FAILURE`
|
||||
- [ ] NULL/leer: `promptText`-Platzhalter
|
||||
- [ ] 1000-Zeichen-Grenze spätestens vor DB-Persistierung erzwungen, Kürzung mit „…"
|
||||
- [ ] Keine rohen Provider-/Exception-Meldungen persistiert
|
||||
|
||||
### #91 Lock-File Pfad
|
||||
- [ ] Absoluter Pfad: direkt verwendet, kein Fallback, Abbruch bei Fehler
|
||||
- [ ] Relativer Pfad: erst JAR-Verzeichnis, dann `user.home`, dann Abbruch
|
||||
- [ ] Parent-Verzeichnisse automatisch angelegt
|
||||
- [ ] Absoluter Pfad beim Start geloggt (INFO)
|
||||
- [ ] Gilt für GUI- und Headless-Start
|
||||
|
||||
### #32 Mausrad-Zoom
|
||||
- [ ] Strg+Scroll: Event grundsätzlich konsumiert (kein paralleles Scrollen)
|
||||
- [ ] Delta-Akkumulation für kleine Trackpad-Deltas
|
||||
- [ ] Zoom 10%–500%, ca. 10 % je Notch
|
||||
- [ ] Ohne Strg: normales Scrollen
|
||||
- [ ] Viewport-Mitte beim Zoom möglichst stabil
|
||||
- [ ] Fit-to-Width-Modus verlassen nach manuellem Zoom
|
||||
- [ ] Zoom-Reset bei neuem PDF (Fit-to-Width)
|
||||
- [ ] Zoom-State konsistent mit Toolbar-Zoom-Buttons
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
<parent>
|
||||
<groupId>de.gecheckt</groupId>
|
||||
<artifactId>pdf-umbenenner-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>pdf-umbenenner-adapter-in-cli</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>de.gecheckt</groupId>
|
||||
<artifactId>pdf-umbenenner-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>pdf-umbenenner-adapter-in-gui</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javafx.stage.FileChooser;
|
||||
|
||||
/**
|
||||
* Funktionales Interface fuer den Datei-Auswaehldialog der GUI.
|
||||
* <p>
|
||||
* Kapselt die Abhaengigkeit zum nativen {@link FileChooser} in einem
|
||||
* injizierbaren Hook, der in Tests durch eine einfache Lambda-Implementierung
|
||||
* ersetzt werden kann. Die Standardimplementierung oeffnet einen echten
|
||||
* nativen Datei-Dialog; Test-Stubs koennen einen festen Pfad zurueckgeben
|
||||
* oder {@code null} simulieren (Abbrechen).
|
||||
* <p>
|
||||
* Im Gegensatz zur frueheren {@code BiFunction}-Variante nimmt dieser Hook
|
||||
* auch die Liste der {@link FileChooser.ExtensionFilter} entgegen, damit der
|
||||
* native Dialog die Filter tatsaechlich anwenden kann.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface FilePickerDialog {
|
||||
|
||||
/**
|
||||
* Oeffnet den Datei-Auswaehldialog und gibt den ausgewaehlten absoluten
|
||||
* Pfad zurueck.
|
||||
*
|
||||
* @param title der Titel des Dialogs
|
||||
* @param initialPath der Anfangspfad als Hinweis; darf leer oder {@code null} sein
|
||||
* @param filters Liste der Dateitypfilter; darf leer sein, aber nicht {@code null}
|
||||
* @return der ausgewaehlte absolute Pfad als String, oder {@code null} wenn abgebrochen
|
||||
*/
|
||||
String pick(String title, String initialPath, List<FileChooser.ExtensionFilter> filters);
|
||||
}
|
||||
+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) {
|
||||
}
|
||||
}
|
||||
+684
-126
File diff suppressed because it is too large
Load Diff
+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);
|
||||
}
|
||||
+41
-10
@@ -5,6 +5,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
@@ -35,6 +36,13 @@ import javafx.application.Platform;
|
||||
* completed retrieval attempt, so later GUI layers can display the result.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Parallele Abrufanfragen (z. B. durch schnellen Provider-Wechsel oder mehrfaches Klicken
|
||||
* auf „Modelle neu laden") werden durch einen Generationszähler entschärft: Jede neue Anfrage
|
||||
* erhöht den Zähler. Wenn das Ergebnis eines Hintergrund-Threads auf dem JavaFX-Thread
|
||||
* verarbeitet wird, prüft der Coordinator, ob die Generationsnummer noch aktuell ist. Veraltete
|
||||
* Ergebnisse (aus einer früheren Anfrage) werden verworfen, sodass stets nur das Ergebnis der
|
||||
* jüngsten Anfrage in die Meldungsliste und die Feldcontainer geschrieben wird.
|
||||
* <p>
|
||||
* The worker thread factory is injectable so tests can supply a synchronous or latch-guarded
|
||||
* executor without spinning a real OS thread.
|
||||
* <p>
|
||||
@@ -43,6 +51,10 @@ import javafx.application.Platform;
|
||||
* {@code Platform.runLater}.
|
||||
*/
|
||||
public final class GuiModelCatalogCoordinator {
|
||||
private static final String LOG_MODEL_FETCH_FMT = "GUI-Modellabruf: {} (Provider: {})";
|
||||
private static final String OPERATION_MODELLABRUF = "Modellabruf";
|
||||
|
||||
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
|
||||
|
||||
@@ -62,6 +74,14 @@ public final class GuiModelCatalogCoordinator {
|
||||
private final Map<AiProviderFamily, GuiModelFieldContainer> fieldContainers =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Generationszähler zur Erkennung veralteter Abruf-Ergebnisse.
|
||||
* Wird bei jeder neuen Anfrage in {@link #triggerModelRetrieval} atomar erhöht.
|
||||
* Hintergrund-Threads erfassen die Generation beim Start; auf dem JavaFX-Thread wird
|
||||
* das Ergebnis verworfen, wenn die gespeicherte Generation nicht mehr aktuell ist.
|
||||
*/
|
||||
private final AtomicLong retrievalGeneration = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* Consumer that delivers the retrieval result. In production this wraps the call in
|
||||
* {@code Platform.runLater}. In tests it can be replaced with a direct call so the result
|
||||
@@ -144,12 +164,23 @@ public final class GuiModelCatalogCoordinator {
|
||||
// Build the request from the current editor state.
|
||||
ModelCatalogRequest request = buildRequest(family, providerState);
|
||||
|
||||
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet.",
|
||||
family.getIdentifier());
|
||||
// Generationsnummer erhöhen – laufende Hintergrund-Threads mit einer älteren
|
||||
// Generationsnummer verwerfen ihr Ergebnis, sobald sie auf dem FX-Thread ankommen.
|
||||
long currentGeneration = retrievalGeneration.incrementAndGet();
|
||||
|
||||
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet (Generation {}).",
|
||||
family.getIdentifier(), currentGeneration);
|
||||
|
||||
Runnable task = () -> {
|
||||
ModelCatalogResult result = modelCatalogPort.fetchAvailableModels(request);
|
||||
resultDelivery.accept(() -> {
|
||||
// Veraltetes Ergebnis verwerfen, wenn inzwischen eine neuere Anfrage gestartet wurde.
|
||||
if (retrievalGeneration.get() != currentGeneration) {
|
||||
LOG.debug("GUI-Modellabruf: Ergebnis für Provider '{}' verworfen"
|
||||
+ " (Generation {} ist nicht mehr aktuell).",
|
||||
family.getIdentifier(), currentGeneration);
|
||||
return;
|
||||
}
|
||||
applyResult(family, container, result, previousManualValue);
|
||||
postResultCallback.run();
|
||||
});
|
||||
@@ -176,7 +207,7 @@ public final class GuiModelCatalogCoordinator {
|
||||
String previousManualValue) {
|
||||
// Remove any previous message entries from an earlier retrieval so messages do not
|
||||
// accumulate across repeated triggers of the same retrieval action.
|
||||
pendingMessages.removeIf(msg -> "Modellabruf".equals(msg.source().orElse("")));
|
||||
pendingMessages.removeIf(msg -> OPERATION_MODELLABRUF.equals(msg.source().orElse("")));
|
||||
|
||||
String displayName = displayNameFor(family);
|
||||
|
||||
@@ -186,28 +217,28 @@ public final class GuiModelCatalogCoordinator {
|
||||
container.applyModelList(models, previousManualValue);
|
||||
String message = "Modellliste für " + displayName + " geladen ("
|
||||
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, "Modellabruf"));
|
||||
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, OPERATION_MODELLABRUF));
|
||||
LOG.info(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
|
||||
}
|
||||
case ModelCatalogResult.EmptyList emptyList -> {
|
||||
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||
String message = "Provider " + displayName
|
||||
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf"));
|
||||
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, OPERATION_MODELLABRUF));
|
||||
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
|
||||
}
|
||||
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
|
||||
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
|
||||
+ ". Manuelle Eingabe aktiv.";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, "Modellabruf"));
|
||||
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, OPERATION_MODELLABRUF));
|
||||
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
|
||||
}
|
||||
case ModelCatalogResult.TechnicalFailure failure -> {
|
||||
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
|
||||
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
|
||||
+ "). Manuelle Eingabe aktiv.";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, "Modellabruf"));
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, OPERATION_MODELLABRUF));
|
||||
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
|
||||
message, failure.errorDetail(), family.getIdentifier());
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
|
||||
/**
|
||||
* GUI-internes Bridge-Interface zwischen dem Prompt-Editor-Tab und dem zugehörigen
|
||||
* Use-Case in der Application-Schicht.
|
||||
* <p>
|
||||
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||
* Es ist eine modul-interne Brücke, über die Bootstrap die vom Use-Case bereitgestellte
|
||||
* Funktionalität in den GUI-Adapter einschleust, ohne dass der GUI-Adapter direkt auf
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PromptPort} oder das Dateisystem
|
||||
* zugreift.
|
||||
* <p>
|
||||
* <strong>Verantwortung:</strong>
|
||||
* <ul>
|
||||
* <li>Prompt-Inhalt für die Anzeige im Editor laden.</li>
|
||||
* <li>Bearbeiteten Inhalt atomar speichern.</li>
|
||||
* <li>Standard-Prompt-Datei anlegen, wenn noch keine vorhanden ist.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Alle Implementierungen dieses Interfaces liegen in {@code pdf-umbenenner-bootstrap}.
|
||||
* Das GUI-Modul kennt ausschließlich den Interface-Typ.
|
||||
*/
|
||||
public interface GuiPromptEditorPort {
|
||||
|
||||
/**
|
||||
* Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Quelle.
|
||||
* <p>
|
||||
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||
*
|
||||
* @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg,
|
||||
* oder einem klassifizierten Fehler; nie {@code null}
|
||||
*/
|
||||
PromptLoadingResult loadCurrentPrompt();
|
||||
|
||||
/**
|
||||
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
|
||||
* <p>
|
||||
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||
*
|
||||
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
|
||||
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code content} null ist
|
||||
*/
|
||||
PromptSaveResult save(String content);
|
||||
|
||||
/**
|
||||
* Legt eine Standard-Prompt-Datei an, falls noch keine vorhanden ist.
|
||||
* <p>
|
||||
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||
*
|
||||
* @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein
|
||||
* @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code suggestion} null ist
|
||||
*/
|
||||
CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
/**
|
||||
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort} erzeugt.
|
||||
* <p>
|
||||
* Wird vom {@link GuiConfigurationEditorWorkspace} genutzt, um nach einem Konfigurations-Laden
|
||||
* oder -Speichern einen neuen Port für den {@link GuiPromptEditorTab} zu erstellen, ohne dass
|
||||
* der GUI-Adapter direkt von Bootstrap-internen Klassen abhängen muss.
|
||||
* <p>
|
||||
* Alle Implementierungen liegen in {@code pdf-umbenenner-bootstrap}.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiPromptEditorPortFactory {
|
||||
|
||||
/**
|
||||
* Erzeugt einen {@link GuiPromptEditorPort} für den angegebenen Prompt-Dateipfad.
|
||||
*
|
||||
* @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
|
||||
* @return vollständig verdrahteter Port; nie {@code null}
|
||||
*/
|
||||
GuiPromptEditorPort create(String promptFilePath);
|
||||
}
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.text.Font;
|
||||
|
||||
/**
|
||||
* Tab „Prompt" im Hauptfenster des GUI-Adapters.
|
||||
* <p>
|
||||
* Ermöglicht das Lesen, Bearbeiten und Speichern der konfigurierten KI-Prompt-Datei
|
||||
* direkt aus der Oberfläche heraus, ohne einen externen Editor öffnen zu müssen.
|
||||
* <p>
|
||||
* <strong>Verhalten:</strong>
|
||||
* <ul>
|
||||
* <li>Beim Öffnen des Tabs wird der aktuelle Prompt-Inhalt auf einem Worker-Thread geladen.</li>
|
||||
* <li>Bearbeitungen erzeugen einen Dirty-State; der Tab-Titel erhält einen Asterisk.</li>
|
||||
* <li>„Speichern" schreibt den Inhalt atomar via {@link GuiPromptEditorPort}.</li>
|
||||
* <li>„Auf Standard zurücksetzen" befüllt die TextArea mit dem Default-Template,
|
||||
* ohne zu speichern.</li>
|
||||
* <li>Bei fehlendem Prompt wird ein Hinweis und ein „Standard-Prompt erstellen"-Button
|
||||
* angezeigt.</li>
|
||||
* <li>Tab-Wechsel oder Schließen mit Dirty-State löst einen Bestätigungsdialog aus.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Threading:</strong> Alle blockierenden Operationen (Laden, Speichern,
|
||||
* Prompt-Datei anlegen) laufen auf einem Worker-Thread. UI-Aktualisierungen erfolgen
|
||||
* ausschließlich via {@code Platform.runLater}.
|
||||
*/
|
||||
public class GuiPromptEditorTab {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiPromptEditorTab.class);
|
||||
|
||||
private static final String TAB_TITLE = "Prompt";
|
||||
private static final String TAB_TITLE_DIRTY = "Prompt *";
|
||||
|
||||
private GuiPromptEditorPort promptEditorPort;
|
||||
/** Konfigurierter Prompt-Dateipfad – wird für CreatePromptFile-Vorschläge benötigt. */
|
||||
private String configuredPromptPath;
|
||||
/** Konfigurierte maximale Titellänge – für den Default-Prompt-Inhalt. */
|
||||
private int maxTitleLength;
|
||||
|
||||
// Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
|
||||
/** Erzeugt Worker-Threads für blockierende Operationen. */
|
||||
Function<Runnable, Thread> threadFactory;
|
||||
/** Übergibt UI-Updates an den JavaFX Application Thread. */
|
||||
Consumer<Runnable> fxDispatcher;
|
||||
|
||||
private final Tab tab = new Tab(TAB_TITLE);
|
||||
private final TextArea textArea = new TextArea();
|
||||
private final Label statusLabel = new Label();
|
||||
private final Button saveButton = new Button("Speichern");
|
||||
private final Button resetButton = new Button("Auf Standard zurücksetzen");
|
||||
private final Button createDefaultButton = new Button("Standard-Prompt erstellen");
|
||||
|
||||
/** Zeigt an, ob der aktuelle Inhalt der TextArea vom geladenen Stand abweicht. */
|
||||
private boolean dirty = false;
|
||||
/** Zuletzt aus der Datei geladener Inhalt (Baseline). */
|
||||
private String loadedContent = null;
|
||||
|
||||
/**
|
||||
* Erstellt den Prompt-Editor-Tab.
|
||||
*
|
||||
* @param promptEditorPort Bridge-Port zum Use-Case; darf nicht {@code null} sein
|
||||
* @param configuredPromptPath konfigurierter Pfad zur Prompt-Datei (für CreatePromptFile);
|
||||
* darf nicht {@code null} sein
|
||||
* @param maxTitleLength konfigurierte maximale Titellänge für den Default-Prompt
|
||||
* @throws NullPointerException wenn {@code promptEditorPort} oder {@code configuredPromptPath} null ist
|
||||
*/
|
||||
public GuiPromptEditorTab(GuiPromptEditorPort promptEditorPort,
|
||||
String configuredPromptPath,
|
||||
int maxTitleLength) {
|
||||
this.promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||
this.configuredPromptPath = Objects.requireNonNull(configuredPromptPath, "configuredPromptPath must not be null");
|
||||
this.maxTitleLength = maxTitleLength;
|
||||
// Standard-Implementierungen für den Produktionsbetrieb
|
||||
this.threadFactory = runnable -> {
|
||||
Thread t = new Thread(runnable, "gui-prompt-editor");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
};
|
||||
this.fxDispatcher = Platform::runLater;
|
||||
buildTab();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert das JavaFX-Tab-Objekt, das dem TabPane hinzugefügt werden kann.
|
||||
*
|
||||
* @return das Tab; nie {@code null}
|
||||
*/
|
||||
public Tab tab() {
|
||||
return tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob der Prompt-Editor ungespeicherte Änderungen enthält.
|
||||
*
|
||||
* @return {@code true}, wenn Dirty-State aktiv ist
|
||||
*/
|
||||
public boolean hasDirtyContent() {
|
||||
return dirty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert den Tab auf eine neue Konfiguration.
|
||||
* <p>
|
||||
* Setzt Port, Prompt-Dateipfad und maximale Titellänge auf die neuen Werte.
|
||||
* Der bisherige Lade-Baseline wird verworfen und der Dirty-State zurückgesetzt.
|
||||
* Ist der Tab zum Zeitpunkt des Aufrufs sichtbar, wird ein erneutes Laden sofort
|
||||
* ausgelöst; andernfalls erfolgt das Laden beim nächsten Öffnen des Tabs.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*
|
||||
* @param newPort neuer Port für Prompt-Operationen; darf nicht {@code null} sein
|
||||
* @param newPromptPath neuer konfigurierter Prompt-Dateipfad; darf nicht {@code null} sein
|
||||
* @param newMaxTitleLength neue konfigurierte maximale Titellänge
|
||||
*/
|
||||
public void notifyConfigurationChanged(GuiPromptEditorPort newPort,
|
||||
String newPromptPath,
|
||||
int newMaxTitleLength) {
|
||||
this.promptEditorPort = Objects.requireNonNull(newPort, "newPort must not be null");
|
||||
this.configuredPromptPath = Objects.requireNonNull(newPromptPath, "newPromptPath must not be null");
|
||||
this.maxTitleLength = newMaxTitleLength;
|
||||
this.loadedContent = null;
|
||||
this.dirty = false;
|
||||
this.tab.setText(TAB_TITLE);
|
||||
this.saveButton.setDisable(true);
|
||||
if (tab.isSelected()) {
|
||||
loadPromptAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft alle ungespeicherten Änderungen und setzt den Tab in den Lade-Bereitschaftszustand.
|
||||
* <p>
|
||||
* Setzt Dirty-State und Tab-Titel zurück. Ist der Tab zum Zeitpunkt des Aufrufs sichtbar,
|
||||
* wird der Prompt-Inhalt sofort neu geladen; andernfalls erfolgt das Laden beim nächsten
|
||||
* Öffnen des Tabs (gesteuert durch den Tab-Selektions-Listener).
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*/
|
||||
public void discardChanges() {
|
||||
this.loadedContent = null;
|
||||
this.dirty = false;
|
||||
this.tab.setText(TAB_TITLE);
|
||||
this.saveButton.setDisable(true);
|
||||
if (tab.isSelected()) {
|
||||
loadPromptAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt einen Bestätigungsdialog, wenn ungespeicherte Änderungen vorhanden sind.
|
||||
* Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen.
|
||||
*
|
||||
* @return {@code true} zum Verwerfen, {@code false} zum Abbrechen
|
||||
*/
|
||||
public boolean confirmDiscardIfDirty() {
|
||||
if (!dirty) {
|
||||
return true;
|
||||
}
|
||||
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
alert.setTitle("Ungespeicherte Änderungen");
|
||||
alert.setHeaderText("Der Prompt-Editor enthält ungespeicherte Änderungen.");
|
||||
alert.setContentText("Möchten Sie die Änderungen verwerfen?");
|
||||
alert.getButtonTypes().setAll(
|
||||
new ButtonType("Verwerfen"),
|
||||
ButtonType.CANCEL);
|
||||
Optional<ButtonType> result = alert.showAndWait();
|
||||
return result.isPresent() && result.get().getText().equals("Verwerfen");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt den aktuellen Prompt-Inhalt auf einem Worker-Thread und zeigt ihn in der TextArea an.
|
||||
* <p>
|
||||
* Muss vom JavaFX Application Thread aufgerufen werden. Die eigentliche I/O-Operation
|
||||
* läuft auf einem Hintergrund-Thread; UI-Updates erfolgen via {@code fxDispatcher}.
|
||||
*/
|
||||
public void loadPromptAsync() {
|
||||
setStatus("Lade Prompt-Datei ...");
|
||||
saveButton.setDisable(true);
|
||||
resetButton.setDisable(true);
|
||||
createDefaultButton.setVisible(false);
|
||||
createDefaultButton.setManaged(false);
|
||||
|
||||
Thread worker = threadFactory.apply(() -> {
|
||||
var result = promptEditorPort.loadCurrentPrompt();
|
||||
fxDispatcher.accept(() -> applyLoadResult(result));
|
||||
});
|
||||
worker.start();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private Aufbau
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void buildTab() {
|
||||
tab.setClosable(false);
|
||||
|
||||
// TextArea – monospace Font für bessere Lesbarkeit
|
||||
textArea.setWrapText(true);
|
||||
textArea.setFont(Font.font("Monospace", 13));
|
||||
textArea.setPrefRowCount(20);
|
||||
textArea.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_TEXTAREA));
|
||||
VBox.setVgrow(textArea, Priority.ALWAYS);
|
||||
|
||||
// Dirty-State-Tracking
|
||||
textArea.textProperty().addListener((obs, oldVal, newVal) -> {
|
||||
if (loadedContent != null) {
|
||||
boolean nowDirty = !newVal.equals(loadedContent);
|
||||
if (nowDirty != dirty) {
|
||||
dirty = nowDirty;
|
||||
tab.setText(dirty ? TAB_TITLE_DIRTY : TAB_TITLE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Status-Label
|
||||
statusLabel.setWrapText(true);
|
||||
statusLabel.setStyle("-fx-text-fill: #555555;");
|
||||
|
||||
// Buttons verdrahten
|
||||
saveButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_SPEICHERN));
|
||||
saveButton.setOnAction(e -> requestSave());
|
||||
|
||||
resetButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_ZURUECKSETZEN));
|
||||
resetButton.setOnAction(e -> resetToDefault());
|
||||
|
||||
createDefaultButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_STANDARD_ANLEGEN));
|
||||
createDefaultButton.setOnAction(e -> requestCreateDefault());
|
||||
createDefaultButton.setVisible(false);
|
||||
createDefaultButton.setManaged(false);
|
||||
|
||||
HBox buttonBar = new HBox(8, saveButton, resetButton, createDefaultButton);
|
||||
buttonBar.setAlignment(Pos.CENTER_LEFT);
|
||||
buttonBar.setPadding(new Insets(6, 0, 0, 0));
|
||||
|
||||
VBox content = new VBox(6, textArea, statusLabel, buttonBar);
|
||||
content.setPadding(new Insets(12));
|
||||
VBox.setVgrow(textArea, Priority.ALWAYS);
|
||||
|
||||
BorderPane root = new BorderPane(content);
|
||||
tab.setContent(root);
|
||||
|
||||
// Beim Öffnen des Tabs laden (falls Konfiguration bereits vorhanden)
|
||||
tab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
|
||||
if (Boolean.TRUE.equals(isSelected) && loadedContent == null) {
|
||||
loadPromptAsync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyLoadResult(de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult result) {
|
||||
if (result instanceof PromptLoadingSuccess success) {
|
||||
loadedContent = success.promptContent();
|
||||
textArea.setText(loadedContent);
|
||||
textArea.setEditable(true);
|
||||
saveButton.setDisable(false);
|
||||
resetButton.setDisable(false);
|
||||
createDefaultButton.setVisible(false);
|
||||
createDefaultButton.setManaged(false);
|
||||
setStatus("Prompt-Datei geladen. Identifikator: " + success.promptIdentifier().identifier());
|
||||
dirty = false;
|
||||
tab.setText(TAB_TITLE);
|
||||
LOG.info("Prompt-Editor: Prompt-Datei erfolgreich geladen (Identifikator: {}).",
|
||||
success.promptIdentifier().identifier());
|
||||
} else if (result instanceof PromptLoadingFailure failure) {
|
||||
boolean fileNotFound = "FILE_NOT_FOUND".equals(failure.failureReason());
|
||||
if (fileNotFound) {
|
||||
// Datei fehlt – Hinweis und Anlegen-Button anzeigen
|
||||
loadedContent = null;
|
||||
textArea.setEditable(false);
|
||||
textArea.clear();
|
||||
saveButton.setDisable(true);
|
||||
resetButton.setDisable(false);
|
||||
createDefaultButton.setVisible(true);
|
||||
createDefaultButton.setManaged(true);
|
||||
setStatus("Keine Prompt-Datei vorhanden. Legen Sie eine Standard-Datei an oder "
|
||||
+ "konfigurieren Sie den Pfad im Konfigurationstab.");
|
||||
LOG.info("Prompt-Editor: Keine Prompt-Datei am konfigurierten Pfad vorhanden.");
|
||||
} else {
|
||||
// Anderer Fehler (I/O, leer usw.)
|
||||
loadedContent = null;
|
||||
textArea.setEditable(false);
|
||||
textArea.clear();
|
||||
saveButton.setDisable(true);
|
||||
resetButton.setDisable(false);
|
||||
createDefaultButton.setVisible(false);
|
||||
createDefaultButton.setManaged(false);
|
||||
setStatus("Fehler beim Laden der Prompt-Datei: " + failure.failureMessage());
|
||||
LOG.warn("Prompt-Editor: Laden fehlgeschlagen ({}): {}",
|
||||
failure.failureReason(), failure.failureMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requestSave() {
|
||||
String currentText = textArea.getText();
|
||||
|
||||
// Leerer Prompt: Bestätigungsdialog
|
||||
if (currentText.trim().isEmpty()) {
|
||||
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
confirm.setTitle("Leerer Prompt");
|
||||
confirm.setHeaderText("Der Prompt ist leer.");
|
||||
confirm.setContentText("Wirklich eine leere Prompt-Datei speichern?");
|
||||
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
Optional<ButtonType> choice = confirm.showAndWait();
|
||||
if (choice.isEmpty() || choice.get() != ButtonType.OK) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStatus("Speichere ...");
|
||||
saveButton.setDisable(true);
|
||||
|
||||
Thread worker = threadFactory.apply(() -> {
|
||||
PromptSaveResult result = promptEditorPort.save(currentText);
|
||||
fxDispatcher.accept(() -> applySaveResult(result, currentText));
|
||||
});
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void applySaveResult(PromptSaveResult result, String savedContent) {
|
||||
saveButton.setDisable(false);
|
||||
if (result instanceof PromptSaveResult.Saved saved) {
|
||||
loadedContent = savedContent;
|
||||
dirty = false;
|
||||
tab.setText(TAB_TITLE);
|
||||
setStatus("Prompt-Datei gespeichert: " + saved.absolutePath());
|
||||
textArea.setEditable(true);
|
||||
LOG.info("Prompt-Editor: Prompt-Datei gespeichert unter {}.", saved.absolutePath());
|
||||
} else if (result instanceof PromptSaveResult.TargetDirectoryMissing missing) {
|
||||
setStatus("Fehler: " + missing.message());
|
||||
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Ordner fehlt: {}", missing.message());
|
||||
} else if (result instanceof PromptSaveResult.WriteFailed failed) {
|
||||
setStatus("Fehler beim Schreiben: " + failed.message());
|
||||
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Schreibfehler: {}", failed.message());
|
||||
} else if (result instanceof PromptSaveResult.AtomicMoveFailed atomicFailed) {
|
||||
setStatus("Fehler: Atomares Speichern fehlgeschlagen (kein Fallback). " + atomicFailed.message());
|
||||
LOG.warn("Prompt-Editor: Atomares Verschieben fehlgeschlagen: {}", atomicFailed.message());
|
||||
}
|
||||
}
|
||||
|
||||
void resetToDefault() {
|
||||
String defaultContent = de.gecheckt.pdf.umbenenner.application.validation
|
||||
.technicaltest.DefaultPromptTemplate.defaultContent(maxTitleLength);
|
||||
textArea.setText(defaultContent);
|
||||
textArea.setEditable(true);
|
||||
saveButton.setDisable(false);
|
||||
setStatus("Standard-Prompt-Inhalt in den Editor geladen (noch nicht gespeichert).");
|
||||
LOG.info("Prompt-Editor: Standard-Prompt-Inhalt in TextArea geladen (nicht gespeichert).");
|
||||
}
|
||||
|
||||
private void requestCreateDefault() {
|
||||
createDefaultButton.setDisable(true);
|
||||
setStatus("Lege Standard-Prompt-Datei an ...");
|
||||
|
||||
CorrectionSuggestion.CreatePromptFile suggestion = new CorrectionSuggestion.CreatePromptFile(
|
||||
configuredPromptPath,
|
||||
"Standard-Prompt-Datei anlegen",
|
||||
maxTitleLength);
|
||||
|
||||
Thread worker = threadFactory.apply(() -> {
|
||||
CorrectionOutcome outcome = promptEditorPort.createDefaultPromptIfMissing(suggestion);
|
||||
fxDispatcher.accept(() -> applyCreateDefaultResult(outcome));
|
||||
});
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void applyCreateDefaultResult(CorrectionOutcome outcome) {
|
||||
createDefaultButton.setDisable(false);
|
||||
if (outcome instanceof CorrectionOutcome.Applied applied) {
|
||||
setStatus(applied.message() + " Lade Inhalt ...");
|
||||
LOG.info("Prompt-Editor: Standard-Prompt-Datei angelegt. Lade neu.");
|
||||
// Inhalt sofort neu laden
|
||||
loadPromptAsync();
|
||||
} else if (outcome instanceof CorrectionOutcome.Failed failed) {
|
||||
setStatus("Fehler beim Anlegen der Standard-Prompt-Datei: " + failed.errorMessage());
|
||||
LOG.warn("Prompt-Editor: Anlegen der Standard-Prompt-Datei fehlgeschlagen: {}", failed.errorMessage());
|
||||
} else if (outcome instanceof CorrectionOutcome.NotAttempted notAttempted) {
|
||||
setStatus("Aktion nicht verfügbar: " + notAttempted.reason());
|
||||
LOG.warn("Prompt-Editor: Anlegen nicht versucht: {}", notAttempted.reason());
|
||||
}
|
||||
}
|
||||
|
||||
private void setStatus(String message) {
|
||||
statusLabel.setText(message);
|
||||
}
|
||||
}
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Separator;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Fünfter Haupt-Tab des JavaFX-Editorfensters: die Scheduler-Steuerungsansicht.
|
||||
* <p>
|
||||
* Zeigt den aktuellen Zustand des automatischen Schedulers und erlaubt dessen
|
||||
* Steuerung über {@link SchedulerControlUseCase}. Der Tab-Inhalt wird im Sekundentakt
|
||||
* durch {@link #updateStatus(SchedulerStatus)} aktualisiert, das von der zentralen
|
||||
* {@link GuiStatusRefreshTimeline} aufgerufen wird.
|
||||
*
|
||||
* <h2>Bereiche</h2>
|
||||
* <ul>
|
||||
* <li><strong>Scheduler-Steuerung</strong>: Status-Anzeige (● Aktiv / ○ Gestoppt),
|
||||
* Start-/Stopp-Schaltflächen, Countdown bis zum nächsten Lauf,
|
||||
* Letzter-Lauf-Info, Fehlermeldung und Intervall-Konfiguration.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
|
||||
* werden. Start-, Stopp- und Speichern-Aktionen werden auf einem dedizierten
|
||||
* Hintergrund-Worker-Thread ({@code gui-scheduler-control}) ausgeführt.
|
||||
*/
|
||||
public final class GuiSchedulerTab {
|
||||
private static final String HEADER_LABEL_STYLE = "-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #7f8c8d;";
|
||||
|
||||
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiSchedulerTab.class);
|
||||
|
||||
private static final String TAB_TITLE = "Scheduler";
|
||||
|
||||
/** Mindestwert für das konfigurierbare Ausführungsintervall. */
|
||||
static final int MIN_INTERVAL_SECONDS = 30;
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault());
|
||||
|
||||
private final Tab tab = new Tab(TAB_TITLE);
|
||||
// Not final: may be updated via onSchedulerAvailable after the tab was created without a use
|
||||
// case (e.g., when auto-load initialises the scheduler after the workspace was already built).
|
||||
// Declared volatile so worker-thread reads (executeStart/Stop) see the write from the FX thread.
|
||||
private volatile Optional<SchedulerControlUseCase> schedulerUseCase;
|
||||
private final Supplier<Boolean> isConfigDirty;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bereich 1: Scheduler-Steuerung
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private final Label statusLabel = new Label("○ Gestoppt");
|
||||
private final Button startButton = new Button("Scheduler starten");
|
||||
private final Button stopButton = new Button("Scheduler stoppen");
|
||||
private final Label nextTickLabel = new Label();
|
||||
private final Label lastRunLabel = new Label("Noch kein Lauf in dieser Sitzung.");
|
||||
private final Label sessionTotalsLabel = new Label();
|
||||
private final Label lastErrorLabel = new Label();
|
||||
private final TextField intervalField = new TextField();
|
||||
private final Label intervalValidationLabel = new Label();
|
||||
|
||||
private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "gui-scheduler-control");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Scheduler-Tab.
|
||||
*
|
||||
* @param schedulerUseCase optionaler Use Case zur Scheduler-Steuerung;
|
||||
* {@code null} wird als leer behandelt
|
||||
* @param isConfigDirty Supplier der {@code true} zurückgibt wenn der
|
||||
* Konfigurationseditor ungespeicherte Änderungen hat;
|
||||
* {@code null} wird als immer {@code false} behandelt
|
||||
*/
|
||||
public GuiSchedulerTab(
|
||||
Optional<SchedulerControlUseCase> schedulerUseCase,
|
||||
Supplier<Boolean> isConfigDirty) {
|
||||
this.schedulerUseCase = Objects.requireNonNullElse(schedulerUseCase, Optional.empty());
|
||||
this.isConfigDirty = isConfigDirty != null ? isConfigDirty : () -> false;
|
||||
tab.setClosable(false);
|
||||
buildUi();
|
||||
applyInitialState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den JavaFX-Tab-Knoten für den Einhang in das {@code TabPane}.
|
||||
*
|
||||
* @return Tab-Knoten; nie {@code null}
|
||||
*/
|
||||
public Tab tab() {
|
||||
return tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Macht den Scheduler-Use-Case für diesen Tab verfügbar, nachdem er nach einem
|
||||
* erfolgreichen Datei-Öffnen initialisiert wurde.
|
||||
* <p>
|
||||
* Wird vom Workspace auf dem JavaFX Application Thread aufgerufen, nachdem der
|
||||
* {@link GuiApplicationContextInitializer} auf einem Hintergrund-Thread einen
|
||||
* {@link SchedulerControlUseCase} geliefert hat. Hat keine Wirkung, wenn bereits
|
||||
* ein Use Case vorhanden ist.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*
|
||||
* @param useCase der neu initialisierte Use Case; darf nicht {@code null} sein
|
||||
*/
|
||||
public void onSchedulerAvailable(SchedulerControlUseCase useCase) {
|
||||
if (schedulerUseCase.isPresent()) {
|
||||
return;
|
||||
}
|
||||
schedulerUseCase = Optional.of(useCase);
|
||||
intervalField.setText(String.valueOf(useCase.getIntervalSeconds()));
|
||||
intervalField.setEditable(true);
|
||||
intervalField.setDisable(false);
|
||||
startButton.setDisable(false);
|
||||
startButton.setTooltip(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den aktuell verdrahteten Scheduler-Use-Case zurück.
|
||||
* <p>
|
||||
* Wird von der zentralen Status-Refresh-Timeline benötigt, weil der Use Case
|
||||
* erst nach erfolgreichem Datei-Öffnen verfügbar wird (z. B. durch Auto-Load
|
||||
* der zuletzt geladenen Konfiguration) und damit nicht im
|
||||
* unveränderlichen {@code GuiStartupContext} steht.
|
||||
*
|
||||
* @return aktueller Use Case oder {@code Optional.empty()} wenn keiner verdrahtet ist
|
||||
*/
|
||||
public Optional<SchedulerControlUseCase> currentSchedulerUseCase() {
|
||||
return schedulerUseCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert alle Tab-Elemente anhand des aktuellen Scheduler-Status.
|
||||
* <p>
|
||||
* Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem
|
||||
* JavaFX Application Thread aufgerufen. Implementiert alle in der Spezifikation
|
||||
* definierten Button-Zustände, Label-Texte und Sichtbarkeitsregeln.
|
||||
*
|
||||
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
|
||||
*/
|
||||
public void updateStatus(SchedulerStatus status) {
|
||||
updateStatusLabel(status);
|
||||
updateButtons(status);
|
||||
updateNextTickLabel(status);
|
||||
updateLastRunLabel(status);
|
||||
updateSessionTotalsLabel(status);
|
||||
updateLastErrorLabel(status);
|
||||
updateIntervalFieldEditability(status);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// UI-Aufbau
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void buildUi() {
|
||||
VBox controlArea = buildControlArea();
|
||||
tab.setContent(controlArea);
|
||||
wireActions();
|
||||
}
|
||||
|
||||
private VBox buildControlArea() {
|
||||
statusLabel.setStyle(HEADER_LABEL_STYLE);
|
||||
|
||||
stopButton.setDisable(true);
|
||||
HBox buttonBox = new HBox(10, startButton, stopButton);
|
||||
|
||||
nextTickLabel.setVisible(false);
|
||||
nextTickLabel.setManaged(false);
|
||||
|
||||
lastRunLabel.setWrapText(true);
|
||||
|
||||
sessionTotalsLabel.setWrapText(true);
|
||||
sessionTotalsLabel.setStyle("-fx-text-fill: #7f8c8d;");
|
||||
sessionTotalsLabel.setVisible(false);
|
||||
sessionTotalsLabel.setManaged(false);
|
||||
|
||||
lastErrorLabel.setStyle("-fx-text-fill: #c0392b;");
|
||||
lastErrorLabel.setWrapText(true);
|
||||
lastErrorLabel.setVisible(false);
|
||||
lastErrorLabel.setManaged(false);
|
||||
|
||||
Label intervalLabel = new Label("Intervall (Sekunden):");
|
||||
intervalField.setPrefColumnCount(10);
|
||||
HBox intervalBox = new HBox(10, intervalLabel, intervalField);
|
||||
intervalBox.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
intervalValidationLabel.setStyle("-fx-text-fill: #c0392b; -fx-font-size: 11px;");
|
||||
intervalValidationLabel.setWrapText(true);
|
||||
intervalValidationLabel.setVisible(false);
|
||||
intervalValidationLabel.setManaged(false);
|
||||
|
||||
VBox controlArea = new VBox(12,
|
||||
statusLabel,
|
||||
buttonBox,
|
||||
nextTickLabel,
|
||||
lastRunLabel,
|
||||
sessionTotalsLabel,
|
||||
lastErrorLabel,
|
||||
new Separator(),
|
||||
intervalBox,
|
||||
intervalValidationLabel);
|
||||
controlArea.setPadding(new Insets(16));
|
||||
return controlArea;
|
||||
}
|
||||
|
||||
private void wireActions() {
|
||||
startButton.setOnAction(e -> executeStart());
|
||||
stopButton.setOnAction(e -> executeStop());
|
||||
|
||||
intervalField.focusedProperty().addListener((obs, wasFocused, focused) -> {
|
||||
if (!focused) {
|
||||
validateAndSaveInterval();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyInitialState() {
|
||||
if (schedulerUseCase.isEmpty()) {
|
||||
startButton.setDisable(true);
|
||||
startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
|
||||
stopButton.setDisable(true);
|
||||
intervalField.setEditable(false);
|
||||
intervalField.setDisable(true);
|
||||
} else {
|
||||
intervalField.setText(String.valueOf(schedulerUseCase.get().getIntervalSeconds()));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// updateStatus-Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void updateStatusLabel(SchedulerStatus status) {
|
||||
switch (status.state()) {
|
||||
case STOPPED -> {
|
||||
statusLabel.setText("○ Gestoppt");
|
||||
statusLabel.setStyle(HEADER_LABEL_STYLE);
|
||||
}
|
||||
case STARTING -> {
|
||||
statusLabel.setText("⟳ Wird gestartet…");
|
||||
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #e67e22;");
|
||||
}
|
||||
case RUNNING_IDLE -> {
|
||||
statusLabel.setText("● Aktiv");
|
||||
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
|
||||
}
|
||||
case RUNNING_BATCH_ACTIVE -> {
|
||||
statusLabel.setText("● Aktiv – Lauf aktiv");
|
||||
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
|
||||
}
|
||||
case STOPPING_BATCH_ACTIVE -> {
|
||||
statusLabel.setText("○ Gestoppt – aktueller Lauf läuft noch");
|
||||
statusLabel.setStyle(HEADER_LABEL_STYLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateButtons(SchedulerStatus status) {
|
||||
boolean noUseCase = schedulerUseCase.isEmpty();
|
||||
boolean configDirty = Boolean.TRUE.equals(isConfigDirty.get());
|
||||
|
||||
switch (status.state()) {
|
||||
case STOPPED -> {
|
||||
stopButton.setDisable(true);
|
||||
if (noUseCase) {
|
||||
startButton.setDisable(true);
|
||||
startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
|
||||
} else if (configDirty) {
|
||||
startButton.setDisable(true);
|
||||
startButton.setTooltip(new Tooltip("Bitte Konfiguration speichern"));
|
||||
} else {
|
||||
startButton.setDisable(false);
|
||||
startButton.setTooltip(null);
|
||||
}
|
||||
}
|
||||
case STARTING -> {
|
||||
startButton.setDisable(true);
|
||||
stopButton.setDisable(true);
|
||||
}
|
||||
case RUNNING_IDLE, RUNNING_BATCH_ACTIVE -> {
|
||||
startButton.setDisable(true);
|
||||
startButton.setTooltip(null);
|
||||
stopButton.setDisable(false);
|
||||
}
|
||||
case STOPPING_BATCH_ACTIVE -> {
|
||||
startButton.setDisable(true);
|
||||
stopButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateNextTickLabel(SchedulerStatus status) {
|
||||
if (status.state() == SchedulerState.RUNNING_IDLE && status.nextTickAt().isPresent()) {
|
||||
long remaining = ChronoUnit.SECONDS.between(Instant.now(), status.nextTickAt().get());
|
||||
if (remaining > 0) {
|
||||
long minutes = remaining / 60;
|
||||
long seconds = remaining % 60;
|
||||
nextTickLabel.setText(String.format("Nächster Lauf in: %02d:%02d", minutes, seconds));
|
||||
} else {
|
||||
nextTickLabel.setText("Lauf steht bevor…");
|
||||
}
|
||||
nextTickLabel.setVisible(true);
|
||||
nextTickLabel.setManaged(true);
|
||||
} else {
|
||||
nextTickLabel.setVisible(false);
|
||||
nextTickLabel.setManaged(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateLastRunLabel(SchedulerStatus status) {
|
||||
if (status.lastRunEndedAt().isPresent() && status.lastRunSummary().isPresent()) {
|
||||
Instant endedAt = status.lastRunEndedAt().get();
|
||||
RunSummary summary = status.lastRunSummary().get();
|
||||
String timeStr = TIME_FORMATTER.format(endedAt);
|
||||
boolean noDocuments = summary.successCount() == 0
|
||||
&& summary.failedCount() == 0;
|
||||
if (noDocuments) {
|
||||
lastRunLabel.setText("Letzter Lauf: " + timeStr + " – keine neuen Dokumente");
|
||||
} else {
|
||||
lastRunLabel.setText("Letzter Lauf: " + timeStr + " – "
|
||||
+ summary.successCount() + " verarbeitet, "
|
||||
+ summary.failedCount() + " Fehler");
|
||||
}
|
||||
} else {
|
||||
lastRunLabel.setText("Noch kein Lauf in dieser Sitzung.");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSessionTotalsLabel(SchedulerStatus status) {
|
||||
Optional<SchedulerSessionTotals> totals = status.sessionTotals();
|
||||
if (totals.isPresent()) {
|
||||
SchedulerSessionTotals t = totals.get();
|
||||
sessionTotalsLabel.setText("Seit Scheduler-Start: "
|
||||
+ t.successCount() + " verarbeitet, "
|
||||
+ t.failedCount() + " Fehler");
|
||||
sessionTotalsLabel.setVisible(true);
|
||||
sessionTotalsLabel.setManaged(true);
|
||||
} else {
|
||||
sessionTotalsLabel.setVisible(false);
|
||||
sessionTotalsLabel.setManaged(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateLastErrorLabel(SchedulerStatus status) {
|
||||
Optional<String> lastError = status.lastError();
|
||||
if (lastError.isPresent() && !lastError.get().isBlank()) {
|
||||
lastErrorLabel.setText("Fehler: " + lastError.get());
|
||||
lastErrorLabel.setVisible(true);
|
||||
lastErrorLabel.setManaged(true);
|
||||
} else {
|
||||
lastErrorLabel.setVisible(false);
|
||||
lastErrorLabel.setManaged(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateIntervalFieldEditability(SchedulerStatus status) {
|
||||
boolean editable = status.state() == SchedulerState.STOPPED
|
||||
&& schedulerUseCase.isPresent()
|
||||
&& !Boolean.TRUE.equals(isConfigDirty.get());
|
||||
intervalField.setEditable(editable);
|
||||
intervalField.setDisable(!editable);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Aktions-Handler
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void executeStart() {
|
||||
LOG.info("GUI: Scheduler-Start angefordert.");
|
||||
startButton.setDisable(true);
|
||||
stopButton.setDisable(true);
|
||||
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
|
||||
try {
|
||||
uc.start();
|
||||
LOG.info("GUI: Scheduler erfolgreich gestartet.");
|
||||
} catch (SchedulerStartException e) {
|
||||
LOG.warn("GUI: Scheduler-Start fehlgeschlagen: {}", e.getMessage());
|
||||
Platform.runLater(() -> showStartErrorAlert(e.getMessage()));
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("GUI: Unerwarteter Fehler beim Starten des Schedulers.", e);
|
||||
Platform.runLater(() -> showStartErrorAlert("Unerwarteter Fehler: " + e.getMessage()));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void executeStop() {
|
||||
LOG.info("GUI: Scheduler-Stopp angefordert.");
|
||||
startButton.setDisable(true);
|
||||
stopButton.setDisable(true);
|
||||
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
|
||||
try {
|
||||
uc.stop();
|
||||
LOG.info("GUI: Scheduler gestoppt.");
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("GUI: Unerwarteter Fehler beim Stoppen des Schedulers.", e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void validateAndSaveInterval() {
|
||||
String text = intervalField.getText() == null ? "" : intervalField.getText().trim();
|
||||
try {
|
||||
int value = Integer.parseInt(text);
|
||||
if (value < MIN_INTERVAL_SECONDS) {
|
||||
showIntervalValidationError(
|
||||
"Mindestintervall ist " + MIN_INTERVAL_SECONDS + " Sekunden.");
|
||||
} else {
|
||||
hideIntervalValidationError();
|
||||
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
|
||||
try {
|
||||
uc.saveIntervalSeconds(value);
|
||||
} catch (RuntimeException e) {
|
||||
LOG.warn("GUI: Fehler beim Speichern des Scheduler-Intervalls: {}", e.getMessage());
|
||||
Platform.runLater(() -> showIntervalValidationError(
|
||||
"Speichern fehlgeschlagen: " + e.getMessage()));
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
showIntervalValidationError("Bitte eine ganze Zahl eingeben.");
|
||||
}
|
||||
}
|
||||
|
||||
private void showIntervalValidationError(String message) {
|
||||
intervalValidationLabel.setText(message);
|
||||
intervalValidationLabel.setVisible(true);
|
||||
intervalValidationLabel.setManaged(true);
|
||||
}
|
||||
|
||||
private void hideIntervalValidationError() {
|
||||
intervalValidationLabel.setVisible(false);
|
||||
intervalValidationLabel.setManaged(false);
|
||||
}
|
||||
|
||||
private static void showStartErrorAlert(String message) {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setTitle("Scheduler-Start fehlgeschlagen");
|
||||
alert.setHeaderText("Der Scheduler konnte nicht gestartet werden.");
|
||||
alert.setContentText(message != null ? message : "Unbekannter Fehler.");
|
||||
alert.showAndWait();
|
||||
}
|
||||
}
|
||||
+310
-13
@@ -11,11 +11,15 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
||||
@@ -46,7 +50,29 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
|
||||
* folder for documents that have not yet been successfully processed, and
|
||||
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
|
||||
* context for documents that were skipped in the current run.
|
||||
* context for documents that were skipped in the current run, the resolved application
|
||||
* version string that the status bar displays at the bottom of the main window, and the
|
||||
* optional {@link SchedulerControlUseCase} for controlling the automatic scheduler.
|
||||
* <p>
|
||||
* The optional {@code applicationContextError} carries a human-readable German error
|
||||
* message when the bootstrap-side application run context could not be initialised at
|
||||
* startup (e.g., invalid or incomplete configuration). An empty value signals that the
|
||||
* run context was built successfully and batch runs can be launched immediately.
|
||||
* <p>
|
||||
* The optional {@code schedulerControlUseCase} is present when the automatic scheduler
|
||||
* was successfully wired at startup. An empty value means scheduler control is not
|
||||
* available in this startup context (e.g., no valid configuration was loaded at startup).
|
||||
* <p>
|
||||
* The optional {@code configurationFileLockPort} is present when the GUI can acquire an
|
||||
* OS-level exclusive lock on the configuration file before a manual batch run. When present,
|
||||
* it is acquired by the {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}
|
||||
* on the worker thread before each run and released in a finally block. An empty value means
|
||||
* no locking is performed (e.g., no valid configuration was loaded at startup, or locking is
|
||||
* not required in this context).
|
||||
* <p>
|
||||
* The {@code applicationContextInitializer} is invoked on a background thread each time the
|
||||
* workspace loads a configuration file (auto-load at startup and manual open). Bootstrap
|
||||
* provides an implementation that builds the application run context and wires the scheduler.
|
||||
* <p>
|
||||
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
|
||||
* know about provider-specific HTTP details or adapter wiring.
|
||||
@@ -67,7 +93,22 @@ public record GuiStartupContext(
|
||||
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
||||
GuiManualFileRenamePort manualFileRenamePort,
|
||||
GuiManualFileCopyPort manualFileCopyPort,
|
||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||
String applicationVersion,
|
||||
GuiPromptEditorPort promptEditorPort,
|
||||
GuiHistoryOverviewPort historyOverviewPort,
|
||||
GuiHistoryDetailsPort historyDetailsPort,
|
||||
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
||||
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
|
||||
GuiPromptEditorPortFactory promptEditorPortFactory,
|
||||
GuiCreateNewDatabasePort createNewDatabasePort,
|
||||
Optional<String> applicationContextError,
|
||||
Optional<SchedulerControlUseCase> schedulerControlUseCase,
|
||||
Optional<ConfigurationFileLockPort> configurationFileLockPort,
|
||||
GuiApplicationContextInitializer applicationContextInitializer) {
|
||||
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.
|
||||
@@ -94,10 +135,19 @@ public record GuiStartupContext(
|
||||
* must not be {@code null}
|
||||
* @param historicalDocumentContextPort bridge that resolves the historical processing context
|
||||
* for skipped documents; must not be {@code null}
|
||||
* @param applicationVersion resolved application version string shown in the status
|
||||
* bar; {@code null} defaults to {@code "dev"}
|
||||
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
|
||||
* {@code null} sein
|
||||
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
|
||||
* darf nicht {@code null} sein
|
||||
* @param applicationContextError optional error message when the application run context
|
||||
* could not be initialised at startup; {@code null} becomes empty
|
||||
*/
|
||||
public GuiStartupContext {
|
||||
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 must not be null");
|
||||
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
|
||||
@@ -126,6 +176,166 @@ public record GuiStartupContext(
|
||||
"manualFileCopyPort must not be null");
|
||||
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
|
||||
"historicalDocumentContextPort must not be null");
|
||||
// Null-Fallback für Testumgebungen ohne gepacktes JAR
|
||||
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
|
||||
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||
historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
|
||||
"historyOverviewPort must not be null");
|
||||
historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
|
||||
"historyDetailsPort must not be null");
|
||||
historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
|
||||
"historyResetDocumentStatusPort must not be null");
|
||||
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
|
||||
"deleteDocumentHistoryPort must not be null");
|
||||
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
|
||||
"promptEditorPortFactory must not be null");
|
||||
createNewDatabasePort = Objects.requireNonNull(createNewDatabasePort,
|
||||
"createNewDatabasePort must not be null");
|
||||
schedulerControlUseCase = Objects.requireNonNullElse(schedulerControlUseCase, Optional.empty());
|
||||
configurationFileLockPort = Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
|
||||
applicationContextInitializer = applicationContextInitializer == null
|
||||
? GuiApplicationContextInitializer.noOp() : applicationContextInitializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,7 +377,10 @@ public record GuiStartupContext(
|
||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
||||
rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort());
|
||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||
rejectingCreateNewDatabasePort(), Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,7 +416,10 @@ public record GuiStartupContext(
|
||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
||||
rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort());
|
||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||
rejectingCreateNewDatabasePort(), Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,7 +455,10 @@ public record GuiStartupContext(
|
||||
technicalTestOrchestrator, correctionExecutionService,
|
||||
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort());
|
||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||
rejectingCreateNewDatabasePort(), Optional.empty());
|
||||
}
|
||||
|
||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||
@@ -309,28 +528,29 @@ public record GuiStartupContext(
|
||||
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||
noOpPathCheckPort,
|
||||
noOpTestService);
|
||||
noOpTestService,
|
||||
() -> java.util.Optional.empty());
|
||||
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionSuggestion.CreateDirectory suggestion) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
||||
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
|
||||
}
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
||||
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
|
||||
}
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
||||
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
|
||||
}
|
||||
};
|
||||
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
|
||||
@@ -353,6 +573,83 @@ public record GuiStartupContext(
|
||||
rejectingResetPort(),
|
||||
rejectingManualFileRenamePort(),
|
||||
rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort());
|
||||
noOpHistoricalDocumentContextPort(),
|
||||
"dev",
|
||||
noOpPromptEditorPort(),
|
||||
noOpHistoryOverviewPort(),
|
||||
noOpHistoryDetailsPort(),
|
||||
noOpHistoryResetPort(),
|
||||
noOpDeleteHistoryPort(),
|
||||
noOpPromptEditorPortFactory(),
|
||||
rejectingCreateNewDatabasePort(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert einen ablehnenden {@link GuiCreateNewDatabasePort}, der jede Anlage
|
||||
* sofort als Fehler zurückgibt. Wird verwendet, wenn kein Bootstrap-seitig
|
||||
* verdrahteter Port vorliegt (z. B. in Tests oder vor dem Laden einer
|
||||
* Konfiguration).
|
||||
*
|
||||
* @return ein ablehnender Port; nie {@code null}
|
||||
*/
|
||||
private static GuiCreateNewDatabasePort rejectingCreateNewDatabasePort() {
|
||||
return (configFilePath, targetFile) -> new de.gecheckt.pdf.umbenenner.application.port.in
|
||||
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed(
|
||||
de.gecheckt.pdf.umbenenner.application.port.in
|
||||
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
||||
"Kein DB-Anlage-Port in diesem Startkontext verfügbar.",
|
||||
null);
|
||||
}
|
||||
|
||||
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
|
||||
return path -> noOpPromptEditorPort();
|
||||
}
|
||||
|
||||
private static GuiPromptEditorPort noOpPromptEditorPort() {
|
||||
return new GuiPromptEditorPort() {
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
|
||||
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
|
||||
"NO_OP", NO_PROMPT_PORT_MSG);
|
||||
}
|
||||
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
|
||||
NO_PROMPT_PORT_MSG, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||
createDefaultPromptIfMissing(
|
||||
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionOutcome.NotAttempted(
|
||||
suggestion, NO_PROMPT_PORT_MSG);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort
|
||||
noOpHistoryOverviewPort() {
|
||||
return (configFilePath, query) -> new de.gecheckt.pdf.umbenenner.application.usecase
|
||||
.DefaultHistoryOverviewUseCase.HistoryOverviewResult(java.util.List.of(), false);
|
||||
}
|
||||
|
||||
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort
|
||||
noOpHistoryDetailsPort() {
|
||||
return (configFilePath, fingerprint) -> java.util.Optional.empty();
|
||||
}
|
||||
|
||||
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort
|
||||
noOpHistoryResetPort() {
|
||||
return (configFilePath, fingerprint) -> { /* kein Reset in diesem Startkontext */ };
|
||||
}
|
||||
|
||||
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort
|
||||
noOpDeleteHistoryPort() {
|
||||
return (configFilePath, fingerprint) -> { /* kein Löschen in diesem Startkontext */ };
|
||||
}
|
||||
}
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Separator;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
/**
|
||||
* Permanente Statuszeile am unteren Rand des Hauptfensters.
|
||||
* <p>
|
||||
* Die Statuszeile zeigt immer drei Segmente:
|
||||
* <ul>
|
||||
* <li><b>Links:</b> Anwendungsversion im Format {@code V<version>}, z. B. {@code Vdev}.</li>
|
||||
* <li><b>Mitte:</b> Aktiver Provider und Modellname aus der geladenen Konfiguration.</li>
|
||||
* <li><b>Rechts:</b> Pfad der geladenen Konfigurationsdatei.</li>
|
||||
* </ul>
|
||||
* Wenn keine Konfiguration geladen ist, zeigen Mitte und Rechts den Text
|
||||
* {@value #KEIN_PROFIL_TEXT}. Die Versionsanzeige ist stets sichtbar.
|
||||
* <p>
|
||||
* Alle Aktualisierungen dieser Komponente müssen auf dem JavaFX Application Thread erfolgen.
|
||||
* Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung.
|
||||
*/
|
||||
public final class GuiStatusBar {
|
||||
private static final String LABEL_STYLE = "-fx-font-size: 11px; -fx-text-fill: #555555;";
|
||||
|
||||
|
||||
|
||||
/** Anzeigetext wenn keine Konfiguration geladen ist. */
|
||||
static final String KEIN_PROFIL_TEXT = "Kein Profil geladen";
|
||||
|
||||
/** Präfix vor der Versionsnummer in der linken Statuszeilen-Zelle. */
|
||||
private static final String VERSION_PREFIX = "V";
|
||||
|
||||
private static final AiProviderFamilyStringConverter PROVIDER_CONVERTER =
|
||||
new AiProviderFamilyStringConverter();
|
||||
|
||||
private final String applicationVersion;
|
||||
private final BorderPane root;
|
||||
private final Label versionLabel;
|
||||
private final Label providerLabel;
|
||||
private final Label configPathLabel;
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Statuszeile mit der angegebenen Anwendungsversion.
|
||||
*
|
||||
* @param applicationVersion die aufgelöste Versionsnummer; {@code null} oder leer führt zum
|
||||
* Fallback {@code "dev"}
|
||||
*/
|
||||
public GuiStatusBar(String applicationVersion) {
|
||||
this.applicationVersion = (applicationVersion == null || applicationVersion.isBlank())
|
||||
? "dev"
|
||||
: applicationVersion;
|
||||
|
||||
// Linkes Segment: Versionsanzeige
|
||||
this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion);
|
||||
this.versionLabel.setStyle(LABEL_STYLE);
|
||||
|
||||
// Mittleres Segment: Provider und Modell
|
||||
this.providerLabel = new Label(KEIN_PROFIL_TEXT);
|
||||
this.providerLabel.setStyle(LABEL_STYLE);
|
||||
this.providerLabel.setAlignment(Pos.CENTER);
|
||||
|
||||
// Rechtes Segment: Konfigurationspfad
|
||||
this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
|
||||
this.configPathLabel.setStyle(LABEL_STYLE);
|
||||
this.configPathLabel.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
// Abstandhalter zwischen den Segmenten
|
||||
Region leftSpacer = new Region();
|
||||
Region rightSpacer = new Region();
|
||||
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
|
||||
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
|
||||
|
||||
HBox content = new HBox(16,
|
||||
versionLabel, leftSpacer,
|
||||
providerLabel, rightSpacer,
|
||||
configPathLabel);
|
||||
content.setAlignment(Pos.CENTER_LEFT);
|
||||
content.setPadding(new Insets(4, 12, 4, 12));
|
||||
content.setStyle("-fx-background-color: #f5f5f5;");
|
||||
|
||||
Separator topSeparator = new Separator();
|
||||
|
||||
this.root = new BorderPane();
|
||||
this.root.setTop(topSeparator);
|
||||
this.root.setCenter(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Wurzelknoten der Statuszeile zurück, der in das Hauptfenster eingebettet wird.
|
||||
*
|
||||
* @return der Wurzelknoten; nie {@code null}
|
||||
*/
|
||||
public BorderPane root() {
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Statuszeile anhand des aktuellen Editor-Zustands.
|
||||
* <p>
|
||||
* Ist kein Dateisnapshot vorhanden, wird {@link #clearConfiguration()} ausgeführt.
|
||||
* Andernfalls werden Provider, Modell und Konfigurationspfad aus dem Zustand ermittelt
|
||||
* und angezeigt.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*
|
||||
* @param state der aktuelle Editor-Zustand; darf nicht {@code null} sein
|
||||
*/
|
||||
public void applyEditorState(GuiConfigurationEditorState state) {
|
||||
if (state == null || !state.hasLoadedFileSnapshot()) {
|
||||
clearConfiguration();
|
||||
return;
|
||||
}
|
||||
String configPath = state.configurationPathText();
|
||||
String providerText = resolveProviderText(state);
|
||||
providerLabel.setText(providerText);
|
||||
configPathLabel.setText(configPath.isBlank() ? KEIN_PROFIL_TEXT : configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Mitte und Rechts der Statuszeile auf den Text {@link #KEIN_PROFIL_TEXT} zurück.
|
||||
* <p>
|
||||
* Die Versionsanzeige bleibt unverändert.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*/
|
||||
public void clearConfiguration() {
|
||||
providerLabel.setText(KEIN_PROFIL_TEXT);
|
||||
configPathLabel.setText(KEIN_PROFIL_TEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den aktuell angezeigten Versionstext zurück (inkl. Präfix {@code V}).
|
||||
* <p>
|
||||
* Für Tests zugänglich.
|
||||
*
|
||||
* @return der angezeigte Versionstext; nie {@code null}
|
||||
*/
|
||||
String versionText() {
|
||||
return versionLabel.getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den aktuell angezeigten Provider-Text zurück.
|
||||
* <p>
|
||||
* Für Tests zugänglich.
|
||||
*
|
||||
* @return der angezeigte Provider-Text; nie {@code null}
|
||||
*/
|
||||
String providerText() {
|
||||
return providerLabel.getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den aktuell angezeigten Konfigurationspfad-Text zurück.
|
||||
* <p>
|
||||
* Für Tests zugänglich.
|
||||
*
|
||||
* @return der angezeigte Konfigurationspfad-Text; nie {@code null}
|
||||
*/
|
||||
String configPathText() {
|
||||
return configPathLabel.getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den anzuzeigenden Provider-Text aus dem Editor-Zustand.
|
||||
* <p>
|
||||
* Das Format ist: {@code Provider: <AnzeigeName> · <Modellname>}, wobei der Modellname
|
||||
* weggelassen wird, wenn er leer ist.
|
||||
*
|
||||
* @param state der Editor-Zustand; darf nicht {@code null} sein
|
||||
* @return der formatierte Provider-Text; nie {@code null}
|
||||
*/
|
||||
private static String resolveProviderText(GuiConfigurationEditorState state) {
|
||||
String activeIdentifier = state.values().activeProviderFamily();
|
||||
if (activeIdentifier == null || activeIdentifier.isBlank()) {
|
||||
return KEIN_PROFIL_TEXT;
|
||||
}
|
||||
AiProviderFamily family = AiProviderFamily.fromIdentifier(activeIdentifier).orElse(null);
|
||||
if (family == null) {
|
||||
return KEIN_PROFIL_TEXT;
|
||||
}
|
||||
String displayName = PROVIDER_CONVERTER.toString(family);
|
||||
GuiProviderConfigurationState providerState = state.values().providerConfiguration(family);
|
||||
String model = providerState != null ? providerState.model() : "";
|
||||
if (model == null || model.isBlank()) {
|
||||
return "Provider: " + displayName;
|
||||
}
|
||||
return "Provider: " + displayName + " · " + model;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
|
||||
import javafx.animation.Animation;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.util.Duration;
|
||||
|
||||
/**
|
||||
* Zentrale Status-Refresh-Timeline für die GUI.
|
||||
* <p>
|
||||
* Startet eine JavaFX-{@link Timeline}, die im Sekundentakt einen Callback aufruft.
|
||||
* Der Callback liest den aktuellen Scheduler-Status und aktualisiert alle betroffenen
|
||||
* Tabs (Batch-Tab, Konfig-Tab, Scheduler-Tab) auf dem JavaFX Application Thread.
|
||||
* <p>
|
||||
* Die Timeline wird beim Aufbau der Haupt-GUI gestartet und beim Beenden der
|
||||
* Anwendung gestoppt. Sie läuft unabhängig davon, welcher Tab gerade sichtbar ist.
|
||||
* <p>
|
||||
* Wenn kein {@link SchedulerControlUseCase} vorhanden ist, wird der Callback trotzdem
|
||||
* aufgerufen – der Aufrufer entscheidet, wie er das leere Optional behandelt.
|
||||
*/
|
||||
public final class GuiStatusRefreshTimeline {
|
||||
|
||||
private final Timeline timeline;
|
||||
|
||||
/**
|
||||
* Erzeugt eine neue Status-Refresh-Timeline.
|
||||
* <p>
|
||||
* Die Timeline ist nach der Konstruktion noch nicht aktiv; {@link #start()} muss
|
||||
* explizit aufgerufen werden.
|
||||
*
|
||||
* @param schedulerControlUseCase optionaler Scheduler-Control-Use-Case;
|
||||
* {@code null} wird als leer behandelt
|
||||
* @param onRefresh Callback der bei jedem Tick auf dem JavaFX Application
|
||||
* Thread aufgerufen wird; darf nicht {@code null} sein
|
||||
*/
|
||||
public GuiStatusRefreshTimeline(
|
||||
Optional<SchedulerControlUseCase> schedulerControlUseCase,
|
||||
Runnable onRefresh) {
|
||||
Objects.requireNonNull(onRefresh, "onRefresh must not be null");
|
||||
this.timeline = new Timeline(
|
||||
new KeyFrame(Duration.seconds(1), e -> onRefresh.run()));
|
||||
this.timeline.setCycleCount(Animation.INDEFINITE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet die Status-Refresh-Timeline.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
* Mehrfache Aufrufe sind unschädlich.
|
||||
*/
|
||||
public void start() {
|
||||
timeline.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppt die Status-Refresh-Timeline.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
* Mehrfache Aufrufe sind unschädlich.
|
||||
*/
|
||||
public void stop() {
|
||||
timeline.stop();
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -64,6 +64,7 @@ public final class GuiTechnicalTestCoordinator {
|
||||
private final TechnicalTestOrchestrator orchestrator;
|
||||
private final Supplier<EditorValidationInput> inputProvider;
|
||||
private final Supplier<String> configFilePathProvider;
|
||||
private final Supplier<String> logDirectoryProvider;
|
||||
private final List<GuiMessageEntry> pendingMessages;
|
||||
private final Consumer<TechnicalTestReport> postResultCallback;
|
||||
|
||||
@@ -89,6 +90,9 @@ public final class GuiTechnicalTestCoordinator {
|
||||
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
|
||||
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
|
||||
* darf nicht {@code null} sein
|
||||
* @param logDirectoryProvider Lieferant des konfigurierten {@code log.directory}-Rohwerts;
|
||||
* gibt eine leere Zeichenkette zurück wenn kein Wert konfiguriert ist;
|
||||
* darf nicht {@code null} sein
|
||||
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
|
||||
* @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||
@@ -96,11 +100,13 @@ public final class GuiTechnicalTestCoordinator {
|
||||
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
|
||||
Supplier<EditorValidationInput> inputProvider,
|
||||
Supplier<String> configFilePathProvider,
|
||||
Supplier<String> logDirectoryProvider,
|
||||
List<GuiMessageEntry> pendingMessages,
|
||||
Consumer<TechnicalTestReport> postResultCallback) {
|
||||
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
|
||||
this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider must not be null");
|
||||
this.configFilePathProvider = Objects.requireNonNull(configFilePathProvider, "configFilePathProvider must not be null");
|
||||
this.logDirectoryProvider = Objects.requireNonNull(logDirectoryProvider, "logDirectoryProvider must not be null");
|
||||
this.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages must not be null");
|
||||
this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
|
||||
this.testThreadFactory = task -> {
|
||||
@@ -113,6 +119,9 @@ public final class GuiTechnicalTestCoordinator {
|
||||
/**
|
||||
* Löst die asynchrone Ausführung des vollständigen technischen Gesamttests aus.
|
||||
* <p>
|
||||
* Vor dem Worker-Start wird die geteilte Nachrichtenliste auf dem FX-Thread geleert;
|
||||
* jeder Aufruf ersetzt die zuvor angefügten Einträge (Replace-Semantik).
|
||||
* <p>
|
||||
* Liest den aktuellen Editorzustand und den Konfigurationsdateipfad, baut einen
|
||||
* {@link TechnicalTestRequest} und startet den {@link TechnicalTestOrchestrator} auf
|
||||
* einem Hintergrund-Worker-Thread. Das Ergebnis wird via {@code resultDelivery} an den
|
||||
@@ -124,9 +133,15 @@ public final class GuiTechnicalTestCoordinator {
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*/
|
||||
public void triggerTechnicalTests() {
|
||||
// Bestehende Nachrichtenliste auf dem FX-Thread leeren, bevor der Worker-Thread
|
||||
// startet. Dadurch laufen clear() und nachfolgende add()-Aufrufe (die per
|
||||
// Platform.runLater wieder auf dem FX-Thread landen) auf demselben Thread und
|
||||
// es entsteht kein Race-Fenster mit der UI.
|
||||
pendingMessages.clear();
|
||||
EditorValidationInput input = inputProvider.get();
|
||||
String configFilePath = configFilePathProvider.get();
|
||||
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.");
|
||||
|
||||
@@ -146,15 +161,14 @@ public final class GuiTechnicalTestCoordinator {
|
||||
* Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
|
||||
* <p>
|
||||
* Fügt für jedes Checkpoint-Ergebnis einen neuen Eintrag zur geteilten Nachrichtenliste
|
||||
* hinzu; vorhandene Einträge bleiben erhalten, sodass die Meldungen über mehrere
|
||||
* Testläufe hinweg akkumulieren. Zusätzlich wird eine Zusammenfassung angehängt.
|
||||
* hinzu. Die Liste wurde zuvor in {@link #triggerTechnicalTests()} geleert, sodass jeder
|
||||
* Aufruf einen frischen Stand erzeugt. Zusätzlich wird eine Zusammenfassung angehängt.
|
||||
* <p>
|
||||
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
|
||||
*
|
||||
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
|
||||
*/
|
||||
private void applyResult(TechnicalTestReport report) {
|
||||
// Akkumulieren: Vorherige Einträge anderer Läufe bleiben erhalten.
|
||||
|
||||
long successCount = 0;
|
||||
long failureErrorCount = 0;
|
||||
@@ -227,6 +241,7 @@ public final class GuiTechnicalTestCoordinator {
|
||||
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
|
||||
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
|
||||
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
|
||||
case LOG_DIRECTORY_USABLE -> "Log-Verzeichnis beschreibbar";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
/**
|
||||
* Zentrale Konstantenklasse für alle Tooltip-Texte der GUI.
|
||||
* <p>
|
||||
* Diese Klasse ist die einzige autoritative Quelle für Tooltip-Beschriftungen aller
|
||||
* interaktiven Elemente in der Desktop-Oberfläche. Alle Tooltip-Strings werden hier
|
||||
* definiert und von den jeweiligen UI-Klassen referenziert. Streustrings im
|
||||
* UI-Code sind unzulässig.
|
||||
* <p>
|
||||
* Tooltip-Texte für Status-Icons werden <em>nicht</em> hier gepflegt – sie stammen
|
||||
* ausschließlich aus {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation},
|
||||
* die die autoritative Quelle für alle statusbezogenen Darstellungsinformationen ist.
|
||||
* <p>
|
||||
* Alle Texte sind deutschsprachig gemäß Spezifikation.
|
||||
* Diese Klasse enthält keine JavaFX-Typen und ist nicht instanziierbar.
|
||||
*/
|
||||
public final class GuiTooltipTexts {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Toolbar-Buttons
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für den Button „Neu". */
|
||||
public static final String TOOLBAR_NEU =
|
||||
"Neue Konfiguration erstellen.";
|
||||
|
||||
/** Tooltip für den Button „Öffnen". */
|
||||
public static final String TOOLBAR_OEFFNEN =
|
||||
"Bestehende Konfigurationsdatei (.properties) öffnen.";
|
||||
|
||||
/** Tooltip für den Button „Speichern". */
|
||||
public static final String TOOLBAR_SPEICHERN =
|
||||
"Aktuelle Konfiguration speichern.";
|
||||
|
||||
/** Tooltip für den Button „Speichern unter". */
|
||||
public static final String TOOLBAR_SPEICHERN_UNTER =
|
||||
"Konfiguration unter neuem Dateipfad speichern.";
|
||||
|
||||
/** Tooltip für den Button „Validieren". */
|
||||
public static final String TOOLBAR_VALIDIEREN =
|
||||
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.";
|
||||
|
||||
/** Tooltip für den Button „Technische Tests ausführen". */
|
||||
public static final String TOOLBAR_TECHNISCHE_TESTS =
|
||||
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Konfigurationstab – Pfade
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für das Eingabefeld „Quellordner". */
|
||||
public static final String PFADE_QUELLORDNER =
|
||||
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.";
|
||||
|
||||
/** Tooltip für das Eingabefeld „Zielordner". */
|
||||
public static final String PFADE_ZIELORDNER =
|
||||
"Ordner für die umbenannten Kopien.";
|
||||
|
||||
/** Tooltip für das Eingabefeld „SQLite-Datei". */
|
||||
public static final String PFADE_SQLITE =
|
||||
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.";
|
||||
|
||||
/** Tooltip für das Eingabefeld „Prompt-Datei". */
|
||||
public static final String PFADE_PROMPT =
|
||||
"Externe Textdatei mit den KI-Anweisungen.";
|
||||
|
||||
/** Tooltip für das Eingabefeld „Lock-Datei". */
|
||||
public static final String PFADE_LOCK_DATEI =
|
||||
"Pfad zur Lock-Datei, die parallele Instanzen verhindert (optional).";
|
||||
|
||||
/** Tooltip für das Eingabefeld „Log-Verzeichnis". */
|
||||
public static final String PFADE_LOG_VERZEICHNIS =
|
||||
"Verzeichnis für Log-Dateien. Leer = Standardverzeichnis logs/ im Programmverzeichnis.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Konfigurationstab – Provider
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für die Provider-ComboBox. */
|
||||
public static final String PROVIDER_COMBOBOX =
|
||||
"Der KI-Dienst, der die Dateinamen generiert.";
|
||||
|
||||
/** Tooltip für das Modell-Eingabefeld (ComboBox oder manuelles TextField). */
|
||||
public static final String PROVIDER_MODELL =
|
||||
"Das konkrete Sprachmodell des gewählten Providers.";
|
||||
|
||||
/** Tooltip für das Eingabefeld „Basis-URL". */
|
||||
public static final String PROVIDER_BASIS_URL =
|
||||
"Basis-URL des KI-Dienstes (z. B. https://api.openai.com/v1).";
|
||||
|
||||
/** Tooltip für das Eingabefeld „Timeout". */
|
||||
public static final String PROVIDER_TIMEOUT =
|
||||
"Zeitlimit für KI-Anfragen in Sekunden.";
|
||||
|
||||
/** Tooltip für das Eingabefeld „API-Key". */
|
||||
public static final String PROVIDER_API_KEY =
|
||||
"API-Schlüssel für den konfigurierten KI-Dienst. Umgebungsvariable hat Vorrang.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Konfigurationstab – Verarbeitungslimits
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für das Eingabefeld „max.text.characters". */
|
||||
public static final String LIMITS_MAX_TEXT_CHARACTERS =
|
||||
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.";
|
||||
|
||||
/** Tooltip für das Eingabefeld „max.pages". */
|
||||
public static final String LIMITS_MAX_PAGES =
|
||||
"Maximale Seitenzahl, die aus einem PDF gelesen wird.";
|
||||
|
||||
/** Tooltip für das Eingabefeld „max.title.length". */
|
||||
public static final String LIMITS_MAX_TITLE_LENGTH =
|
||||
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10–120.";
|
||||
|
||||
/** Tooltip für das Eingabefeld „max.retries.transient". */
|
||||
public static final String LIMITS_MAX_RETRIES =
|
||||
"Maximale Anzahl transienter Wiederholversuche je Dokument (Ganzzahl ≥ 1).";
|
||||
|
||||
/** Tooltip für das Eingabefeld „Log-Level". */
|
||||
public static final String LIMITS_LOG_LEVEL =
|
||||
"Log-Detailstufe (z. B. INFO, DEBUG, WARN). Leer = Standardwert INFO.";
|
||||
|
||||
/** Tooltip für die Checkbox „Sensible KI-Ausgabe". */
|
||||
public static final String LIMITS_SENSIBLE_KI_AUSGABE =
|
||||
"Vollständige KI-Antworten in die Log-Datei schreiben (nur für Diagnosezwecke empfohlen).";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verarbeitungslauf-Tab – Dateiname-Editor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für das Dateiname-Textfeld im Dateiname-Editor. */
|
||||
public static final String DATEINAME_TEXTFELD =
|
||||
"Dateiname bearbeiten. Format: JJJJ-MM-TT - Titel.pdf";
|
||||
|
||||
/** Tooltip für den Button „Dateiname übernehmen". */
|
||||
public static final String DATEINAME_UEBERNEHMEN =
|
||||
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.";
|
||||
|
||||
/** Tooltip für den Button „Zurücksetzen auf KI-Vorschlag". */
|
||||
public static final String DATEINAME_ZURUECKSETZEN =
|
||||
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verarbeitungslauf-Tab – Laufsteuerung und Tabelle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für den Button „Starten". */
|
||||
public static final String BATCHRUN_STARTEN =
|
||||
"Verarbeitungslauf starten: alle ausstehenden PDF-Dateien aus dem Quellordner verarbeiten.";
|
||||
|
||||
/** Tooltip für den Button „Abbrechen". */
|
||||
public static final String BATCHRUN_ABBRECHEN =
|
||||
"Laufenden Verarbeitungslauf abbrechen. Bereits abgeschlossene Dateien bleiben gespeichert.";
|
||||
|
||||
/** Tooltip für den Button „Erneut verarbeiten". */
|
||||
public static final String BATCHRUN_ERNEUT_VERARBEITEN =
|
||||
"Markierte Einträge erneut zur Verarbeitung freigeben (setzt Status auf READY_FOR_AI).";
|
||||
|
||||
/** Tooltip für den Button „Status zurücksetzen" im Verarbeitungslauf-Tab. */
|
||||
public static final String BATCHRUN_STATUS_ZURUECKSETZEN =
|
||||
"Status der markierten Einträge zurücksetzen, damit sie beim nächsten Lauf verarbeitet werden.";
|
||||
|
||||
/** Tooltip für die Master-Checkbox im Tabellenkopf des Verarbeitungslauf-Tabs. */
|
||||
public static final String BATCHRUN_MASTER_CHECKBOX =
|
||||
"Alle sichtbaren Einträge markieren oder Markierung aufheben.";
|
||||
|
||||
/** Tooltip für den Meldungsbereich im Verarbeitungslauf-Tab. */
|
||||
public static final String BATCHRUN_MESSAGE_AREA =
|
||||
"Statusmeldungen und Fortschrittsinformationen des aktuellen Verarbeitungslaufs.";
|
||||
|
||||
/** Tooltip für den Navigations-Button „Vorherige Seite" in der PDF-Vorschau. */
|
||||
public static final String PREVIEW_VORHERIGE_SEITE =
|
||||
"Vorherige Seite der Vorschau anzeigen.";
|
||||
|
||||
/** Tooltip für den Navigations-Button „Nächste Seite" in der PDF-Vorschau. */
|
||||
public static final String PREVIEW_NAECHSTE_SEITE =
|
||||
"Nächste Seite der Vorschau anzeigen.";
|
||||
|
||||
/** Tooltip für Spalte „Status" in der Verarbeitungslauf-Tabelle. */
|
||||
public static final String BATCHRUN_COL_STATUS =
|
||||
"Verarbeitungsergebnis: Erfolg, Fehler oder übersprungen.";
|
||||
|
||||
/** Tooltip für Spalte „Originaldateiname" in der Verarbeitungslauf-Tabelle. */
|
||||
public static final String BATCHRUN_COL_ORIGINALDATEINAME =
|
||||
"Ursprünglicher Dateiname der verarbeiteten PDF-Datei.";
|
||||
|
||||
/** Tooltip für Spalte „Neuer Dateiname" in der Verarbeitungslauf-Tabelle. */
|
||||
public static final String BATCHRUN_COL_NEUER_DATEINAME =
|
||||
"Von der KI vorgeschlagener, normierter Dateiname.";
|
||||
|
||||
/** Tooltip für Spalte „Datum" in der Verarbeitungslauf-Tabelle. */
|
||||
public static final String BATCHRUN_COL_DATUM =
|
||||
"Datum des Dokuments laut KI-Analyse.";
|
||||
|
||||
/** Tooltip für Spalte „Dauer" in der Verarbeitungslauf-Tabelle. */
|
||||
public static final String BATCHRUN_COL_DAUER =
|
||||
"Verarbeitungsdauer für diese Datei.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verlauf-Tab – Detailbereich
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für den KI-Begründungs-Bereich im Verlauf-Tab. */
|
||||
public static final String VERLAUF_REASONING_AREA =
|
||||
"KI-Begründung des ausgewählten Verarbeitungsversuchs.";
|
||||
|
||||
/** Tooltip für den Fehlerursachen-Bereich im Verlauf-Tab. */
|
||||
public static final String VERLAUF_FAILURE_AREA =
|
||||
"Fehlermeldung des letzten Fehler-Versuchs für dieses Dokument.";
|
||||
|
||||
/** Tooltip für Spalte „Status" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_COL_STATUS =
|
||||
"Aktueller Gesamtstatus des Dokuments.";
|
||||
|
||||
/** Tooltip für Spalte „Quelldatei" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_COL_QUELLDATEI =
|
||||
"Ursprünglicher Dateiname der PDF-Quelldatei.";
|
||||
|
||||
/** Tooltip für Spalte „Zieldatei" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_COL_ZIELDATEI =
|
||||
"Vom System erzeugter, normierter Dateiname im Zielordner.";
|
||||
|
||||
/** Tooltip für Spalte „Letzter Versuch" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_COL_LETZTER_VERSUCH =
|
||||
"Zeitpunkt des zuletzt abgeschlossenen Verarbeitungsversuchs.";
|
||||
|
||||
/** Tooltip für Spalte „Versuche" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_COL_VERSUCHE =
|
||||
"Gesamtanzahl der Verarbeitungsversuche für dieses Dokument.";
|
||||
|
||||
/** Tooltip für Spalte „#" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_VERSUCHE_COL_NR =
|
||||
"Laufende Nummer des Verarbeitungsversuchs.";
|
||||
|
||||
/** Tooltip für Spalte „Datum" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_VERSUCHE_COL_DATUM =
|
||||
"Endzeitpunkt dieses Verarbeitungsversuchs.";
|
||||
|
||||
/** Tooltip für Spalte „Status" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_VERSUCHE_COL_STATUS =
|
||||
"Ergebnis dieses Verarbeitungsversuchs.";
|
||||
|
||||
/** Tooltip für Spalte „Provider" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_VERSUCHE_COL_PROVIDER =
|
||||
"KI-Provider, der für diesen Versuch verwendet wurde.";
|
||||
|
||||
/** Tooltip für Spalte „Modell" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_VERSUCHE_COL_MODELL =
|
||||
"Konkretes Sprachmodell, das für diesen Versuch verwendet wurde.";
|
||||
|
||||
/** Tooltip für Spalte „Vorgeschlagener Name" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||
public static final String VERLAUF_VERSUCHE_COL_VORGESCHLAGENER_NAME =
|
||||
"Vom System erzeugter Zieldateiname für diesen Versuch.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Konfigurations-Tab – Meldungsbereich und Modell-Neu-Laden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für den Button „Meldungen leeren". */
|
||||
public static final String TOOLBAR_MELDUNGEN_LEEREN =
|
||||
"Alle Meldungen im Meldungsbereich entfernen.";
|
||||
|
||||
/** Tooltip für den Button „Modelle neu laden". */
|
||||
public static final String PROVIDER_MODELLE_NEU_LADEN =
|
||||
"Verfügbare Modelle vom konfigurierten Provider neu abrufen.";
|
||||
|
||||
/** Tooltip für den Ordner-/Datei-Browser-Button. */
|
||||
public static final String PFADE_BROWSER_BUTTON =
|
||||
"Ordner oder Datei über den Datei-Dialog auswählen.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Prompt-Tab – Textbereich
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für den Prompt-Textbereich im Prompt-Editor-Tab. */
|
||||
public static final String PROMPT_TEXTAREA =
|
||||
"KI-Anweisungstext. Dieser Prompt wird bei jedem Verarbeitungsversuch an das Sprachmodell gesendet.";
|
||||
|
||||
/** Tooltip für den Button „Speichern" im Prompt-Editor-Tab. */
|
||||
public static final String PROMPT_SPEICHERN =
|
||||
"Prompt-Datei speichern (atomar, UTF-8).";
|
||||
|
||||
/** Tooltip für den Button „Auf Standard zurücksetzen" im Prompt-Editor-Tab. */
|
||||
public static final String PROMPT_ZURUECKSETZEN =
|
||||
"Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern.";
|
||||
|
||||
/** Tooltip für den Button „Standard-Prompt erstellen" im Prompt-Editor-Tab. */
|
||||
public static final String PROMPT_STANDARD_ANLEGEN =
|
||||
"Standard-Prompt-Datei am konfigurierten Pfad anlegen.";
|
||||
|
||||
/** Nicht instanziierbar – reine Konstantenklasse. */
|
||||
private GuiTooltipTexts() {
|
||||
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||
}
|
||||
}
|
||||
+117
-4
@@ -7,7 +7,12 @@ import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Menu;
|
||||
import javafx.scene.control.MenuBar;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.WindowEvent;
|
||||
|
||||
@@ -25,6 +30,10 @@ import javafx.stage.WindowEvent;
|
||||
*
|
||||
* <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert.
|
||||
* Über das Tray-Kontextmenü kann das Fenster wieder geöffnet oder die Anwendung beendet werden.
|
||||
*
|
||||
* <p>Nach dem Anzeigen des Hauptfensters startet eine zentrale {@link GuiStatusRefreshTimeline}
|
||||
* (1 Hz), die den aktuellen Scheduler-Status liest und alle betroffenen Tabs aktualisiert.
|
||||
* Die Timeline wird beim Beenden der Anwendung gestoppt.
|
||||
*/
|
||||
public class PdfUmbenennerGuiApplication extends Application {
|
||||
|
||||
@@ -33,6 +42,9 @@ public class PdfUmbenennerGuiApplication extends Application {
|
||||
private static final double DEFAULT_HEIGHT = 800;
|
||||
|
||||
private SystemTrayManager trayManager;
|
||||
private GuiConfigurationEditorWorkspace workspace;
|
||||
private GuiStartupContext guiStartupContext;
|
||||
private GuiStatusRefreshTimeline refreshTimeline;
|
||||
|
||||
/**
|
||||
* Creates a new instance of the JavaFX application.
|
||||
@@ -48,6 +60,8 @@ public class PdfUmbenennerGuiApplication extends Application {
|
||||
* 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
|
||||
* 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}
|
||||
*/
|
||||
@@ -63,13 +77,26 @@ public class PdfUmbenennerGuiApplication extends Application {
|
||||
new Image(getClass().getResourceAsStream("/icons/Icon128.png"))
|
||||
);
|
||||
|
||||
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
|
||||
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
|
||||
guiStartupContext = GuiStartupContextHolder.currentOrBlank();
|
||||
workspace = new GuiConfigurationEditorWorkspace(guiStartupContext);
|
||||
|
||||
// Wire the title-update listener so the stage title stays in sync with the dirty state.
|
||||
workspace.titleUpdateListener = primaryStage::setTitle;
|
||||
|
||||
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
|
||||
// Statuszeile anlegen und mit dem Workspace verdrahten
|
||||
GuiStatusBar statusBar = new GuiStatusBar(guiStartupContext.applicationVersion());
|
||||
workspace.statusBarStateListener = statusBar::applyEditorState;
|
||||
|
||||
// Menüleiste mit Datenbank-Menü („Neue Datenbank anlegen…")
|
||||
MenuBar menuBar = buildMenuBar(workspace);
|
||||
|
||||
// Statuszeile unterhalb des Workspace-Inhalts einbetten
|
||||
BorderPane outerLayout = new BorderPane();
|
||||
outerLayout.setTop(menuBar);
|
||||
outerLayout.setCenter(workspace.root());
|
||||
outerLayout.setBottom(statusBar.root());
|
||||
|
||||
Scene scene = new Scene(outerLayout, DEFAULT_WIDTH, DEFAULT_HEIGHT);
|
||||
primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
|
||||
primaryStage.setScene(scene);
|
||||
|
||||
@@ -83,28 +110,114 @@ public class PdfUmbenennerGuiApplication extends Application {
|
||||
installTrayCloseHandler(primaryStage, workspace);
|
||||
}
|
||||
|
||||
// Scheduler-Close-Guard als äußerste Schicht: verhindert Beenden während Scheduler aktiv
|
||||
installSchedulerCloseGuard(primaryStage);
|
||||
|
||||
primaryStage.setMaximized(true);
|
||||
primaryStage.show();
|
||||
|
||||
// Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden.
|
||||
workspace.autoLoadLastConfiguration();
|
||||
|
||||
// Zentrale Status-Refresh-Timeline starten (1 Hz)
|
||||
refreshTimeline = new GuiStatusRefreshTimeline(
|
||||
guiStartupContext.schedulerControlUseCase(),
|
||||
this::refreshAllTabStates);
|
||||
refreshTimeline.start();
|
||||
|
||||
LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the JavaFX runtime when the application is stopping.
|
||||
* <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
|
||||
public void stop() {
|
||||
LOG.info("GUI: JavaFX-Anwendung wird beendet.");
|
||||
if (refreshTimeline != null) {
|
||||
refreshTimeline.stop();
|
||||
}
|
||||
if (trayManager != null) {
|
||||
trayManager.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den aktuellen Scheduler-Status und aktualisiert alle betroffenen Tabs.
|
||||
* <p>
|
||||
* Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem JavaFX
|
||||
* Application Thread aufgerufen. Wenn kein {@link SchedulerControlUseCase} vorhanden
|
||||
* ist, wird der Aufruf ohne Fehler übersprungen.
|
||||
*/
|
||||
private void refreshAllTabStates() {
|
||||
// Den Use Case nicht aus dem unveränderlichen GuiStartupContext lesen, sondern
|
||||
// den zur Laufzeit (z. B. durch Auto-Load) verdrahteten Use Case verwenden.
|
||||
// Andernfalls bliebe der Stop-Button dauerhaft deaktiviert, weil updateStatus
|
||||
// nie aufgerufen würde.
|
||||
workspace.refreshSchedulerStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Menüleiste für das Hauptfenster auf.
|
||||
* <p>
|
||||
* Aktuell enthält sie genau einen Eintrag: das Menü „Datenbank" mit der Aktion
|
||||
* „Neue Datenbank anlegen…". Diese delegiert an
|
||||
* {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}.
|
||||
* <p>
|
||||
* Der Menüpunkt ist deaktiviert, solange ein Verarbeitungslauf aktiv ist oder
|
||||
* bereits eine DB-Anlage läuft. Die Reaktivierung erfolgt automatisch, sobald
|
||||
* der Workspace die DB-Busy-Sperre wieder aufhebt.
|
||||
*
|
||||
* @param workspace der Workspace, an den die Aktionen delegieren; nie {@code null}
|
||||
* @return die fertig konfigurierte Menüleiste
|
||||
*/
|
||||
private MenuBar buildMenuBar(GuiConfigurationEditorWorkspace workspace) {
|
||||
Menu databaseMenu = new Menu("Datenbank");
|
||||
MenuItem createNewItem = new MenuItem("Neue Datenbank anlegen…");
|
||||
createNewItem.setOnAction(event -> workspace.requestCreateNewDatabase());
|
||||
// Sperre während eines aktiven Verarbeitungslaufs oder einer laufenden DB-Anlage
|
||||
createNewItem.disableProperty().bind(workspace.batchRunRunningProperty()
|
||||
.or(workspace.dbBusyForDatabaseCreationProperty()));
|
||||
databaseMenu.getItems().add(createNewItem);
|
||||
return new MenuBar(databaseMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt den Scheduler-Close-Guard als äußerste Schicht des Close-Request-Handlers an.
|
||||
* <p>
|
||||
* Ist kein {@link de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase}
|
||||
* vorhanden, bleibt der bestehende Handler unverändert. Ist der Scheduler aktiv
|
||||
* (Zustand != {@code STOPPED}), wird das Schließen verhindert und ein
|
||||
* Informationsdialog angezeigt. Ist der Scheduler gestoppt, wird der bisherige
|
||||
* Handler (SystemTray + Workspace-Dirty-Guard) aufgerufen.
|
||||
*
|
||||
* @param stage das primäre Fenster; darf nicht {@code null} sein
|
||||
*/
|
||||
private void installSchedulerCloseGuard(Stage stage) {
|
||||
EventHandler<WindowEvent> existingHandler = stage.getOnCloseRequest();
|
||||
stage.setOnCloseRequest(event -> {
|
||||
// Use Case dynamisch über den Workspace lesen, weil der Scheduler erst
|
||||
// nach erfolgreichem Datei-Öffnen (z. B. Auto-Load) verdrahtet wird und
|
||||
// damit nicht zwingend im unveränderlichen GuiStartupContext steht.
|
||||
if (workspace.isSchedulerActive()) {
|
||||
event.consume();
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
alert.setTitle("Anwendung kann nicht beendet werden");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText(
|
||||
"Ein Lauf ist aktiv oder der Scheduler läuft.\n"
|
||||
+ "Bitte beende den Scheduler bzw. warte auf das Ende des Laufs.");
|
||||
alert.showAndWait();
|
||||
return;
|
||||
}
|
||||
if (existingHandler != null) {
|
||||
existingHandler.handle(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
|
||||
* System-Tray minimiert statt es zu schließen.
|
||||
|
||||
+5
-3
@@ -2,7 +2,7 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
/**
|
||||
* Ü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>
|
||||
* Die Klasse wertet die englischsprachige Fehlermeldung aus dem Verarbeitungsversuch
|
||||
* 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
|
||||
* und prüft die definierten Schlüsselbegriffe in festgelegter Reihenfolge,
|
||||
* damit spezifischere Muster vor allgemeineren greifen.
|
||||
* <p>
|
||||
* Die Klasse wird sowohl im Verarbeitungslauf-Tab als auch im Verlauf-Tab verwendet.
|
||||
*/
|
||||
final class AiFailureMessageTranslator {
|
||||
public final class AiFailureMessageTranslator {
|
||||
|
||||
private AiFailureMessageTranslator() {
|
||||
}
|
||||
@@ -28,7 +30,7 @@ final class AiFailureMessageTranslator {
|
||||
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
|
||||
* @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()) {
|
||||
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
|
||||
}
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
|
||||
/**
|
||||
* Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs
|
||||
* die aggregierten Ergebnisse anzeigt.
|
||||
*
|
||||
* <p>Das Banner erscheint nach Laufabschluss unterhalb des Fortschrittsbalkens und
|
||||
* oberhalb der Ergebnistabelle. Es zeigt nur Kategorien, deren Zähler größer als null
|
||||
* ist. Folgende Status werden nicht gezählt und tauchen nie im Banner auf:
|
||||
* {@code READY_FOR_AI}, {@code PROPOSAL_READY} und {@code PROCESSING} sind im
|
||||
* Enum {@link DocumentCompletionStatus} nicht enthalten – alle enthaltenen Werte
|
||||
* werden gezählt, außer Einträgen mit {@code resetPending=true}, da diese keinen
|
||||
* abgeschlossenen Zustand darstellen.
|
||||
*
|
||||
* <p>Farbe ist niemals das einzige Unterscheidungsmerkmal: Jedes Segment enthält
|
||||
* ein Icon und einen Text.
|
||||
*
|
||||
* <p>Die öffentlichen Methoden {@link #clear()} und {@link #update(Map)} sind
|
||||
* thread-agnostisch definiert, aber müssen auf dem JavaFX Application Thread aufgerufen
|
||||
* werden (oder das Banner muss via {@code Platform.runLater} aktualisiert werden).
|
||||
* Die Aggregations-Hilfsmethode {@link #aggregateCounts(Iterable)} ist vollständig
|
||||
* unabhängig von JavaFX und kann auf jedem Thread aufgerufen werden.
|
||||
*/
|
||||
public final class BatchRunSummaryBanner {
|
||||
|
||||
/** Trennzeichen zwischen den Kategoriesegmenten. */
|
||||
private static final String SEGMENT_SEPARATOR = " · ";
|
||||
|
||||
/** Abstand zwischen den Label-Segmenten in Pixeln. */
|
||||
private static final int SPACING = 0;
|
||||
|
||||
/** Innerer Abstand des Containers in Pixeln (oben/unten). */
|
||||
private static final double PADDING_V = 4.0;
|
||||
|
||||
/** Standardfarbe für den Summentext. */
|
||||
private static final String STYLE_DEFAULT = "-fx-font-size: 12;";
|
||||
|
||||
/**
|
||||
* Alle {@link DocumentCompletionStatus}-Werte, die im Banner angezeigt werden,
|
||||
* in der verbindlichen Anzeigereihenfolge gemäß Spezifikation.
|
||||
*/
|
||||
private static final List<DocumentCompletionStatus> DISPLAYED_ORDER = List.of(
|
||||
DocumentCompletionStatus.SUCCESS,
|
||||
DocumentCompletionStatus.FAILED_RETRYABLE,
|
||||
DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
|
||||
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE
|
||||
);
|
||||
|
||||
/** Wurzel-Container des Banners – wird in das Tab-Layout eingebettet. */
|
||||
private final HBox container;
|
||||
|
||||
/** Label, das den kompletten Bannertext als Inline-Segmente trägt. */
|
||||
private final Label contentLabel;
|
||||
|
||||
/**
|
||||
* Erstellt ein neues, initial unsichtbares Summary-Banner.
|
||||
*/
|
||||
public BatchRunSummaryBanner() {
|
||||
contentLabel = new Label();
|
||||
contentLabel.setStyle(STYLE_DEFAULT);
|
||||
contentLabel.setWrapText(false);
|
||||
|
||||
container = new HBox(SPACING, contentLabel);
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;");
|
||||
|
||||
// Initial unsichtbar, nimmt keinen Platz ein
|
||||
container.setVisible(false);
|
||||
container.setManaged(false);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Öffentliche API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Versteckt das Banner und leert seinen Inhalt.
|
||||
*
|
||||
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*/
|
||||
public void clear() {
|
||||
contentLabel.setText("");
|
||||
container.setVisible(false);
|
||||
container.setManaged(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert das Banner mit den aggregierten Zählern und macht es sichtbar.
|
||||
*
|
||||
* <p>Zeigt nur Kategorien mit Anzahl > 0. Wenn alle Zähler null sind (leerer Lauf),
|
||||
* wird das Banner versteckt.
|
||||
*
|
||||
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*
|
||||
* @param counts Zuordnung von Verarbeitungsstatus zu Anzahl;
|
||||
* fehlende Status werden als 0 interpretiert; darf nicht null sein
|
||||
*/
|
||||
public void update(Map<DocumentCompletionStatus, Integer> counts) {
|
||||
Objects.requireNonNull(counts, "counts darf nicht null sein");
|
||||
|
||||
String text = buildBannerText(counts);
|
||||
if (text.isEmpty()) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
contentLabel.setText(text);
|
||||
container.setVisible(true);
|
||||
container.setManaged(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout.
|
||||
*
|
||||
* @return der Container-Knoten; nie null
|
||||
*/
|
||||
public HBox getNode() {
|
||||
return container;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Aggregations-Hilfe (thread-agnostisch, testbar ohne JavaFX)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zählt die Anzahl jedes {@link DocumentCompletionStatus} in der übergebenen
|
||||
* Iterable. Einträge mit {@code resetPending=true} werden ignoriert, da sie
|
||||
* keinen abgeschlossenen Verarbeitungszustand darstellen.
|
||||
*
|
||||
* <p>Diese Methode ist vollständig unabhängig von JavaFX und kann auf jedem
|
||||
* Thread aufgerufen werden.
|
||||
*
|
||||
* @param rows die Ergebniszeilen des Laufs; darf nicht null sein;
|
||||
* null-Elemente werden übersprungen
|
||||
* @return eine Map mit der Anzahl je Status; enthält alle anzuzeigenden
|
||||
* Status (fehlende haben Wert 0); nie null
|
||||
*/
|
||||
public static Map<DocumentCompletionStatus, Integer> aggregateCounts(
|
||||
Iterable<? extends GuiBatchRunResultRow> rows) {
|
||||
Objects.requireNonNull(rows, "rows darf nicht null sein");
|
||||
|
||||
Map<DocumentCompletionStatus, Integer> counts = new EnumMap<>(DocumentCompletionStatus.class);
|
||||
// Alle anzuzeigenden Status mit 0 vorbelegen
|
||||
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
|
||||
counts.put(status, 0);
|
||||
}
|
||||
|
||||
for (GuiBatchRunResultRow row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
// Reset-Pending-Zeilen zählen nicht – sie haben noch keinen abgeschlossenen Status
|
||||
if (row.resetPending()) {
|
||||
continue;
|
||||
}
|
||||
DocumentCompletionStatus status = row.status();
|
||||
// Nur anzuzeigende Status zählen (entspricht dem Ausschluss von
|
||||
// Übergangszuständen wie READY_FOR_AI, PROPOSAL_READY, PROCESSING)
|
||||
if (counts.containsKey(status)) {
|
||||
counts.merge(status, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Interne Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Erzeugt den angezeigten Bannertext aus den Zählern.
|
||||
* Liefert einen leeren String wenn alle Zähler null sind.
|
||||
*
|
||||
* @param counts die Zähler je Status; darf nicht null sein
|
||||
* @return der fertige Bannertext oder ein leerer String
|
||||
*/
|
||||
static String buildBannerText(Map<DocumentCompletionStatus, Integer> counts) {
|
||||
List<String> segments = new ArrayList<>();
|
||||
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
|
||||
int count = counts.getOrDefault(status, 0);
|
||||
if (count > 0) {
|
||||
String icon = ProcessingStatusPresentation.iconFor(status);
|
||||
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
|
||||
segments.add(icon + " " + count + " " + category);
|
||||
}
|
||||
}
|
||||
return String.join(SEGMENT_SEPARATOR, segments);
|
||||
}
|
||||
}
|
||||
+12
@@ -6,17 +6,20 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.util.Duration;
|
||||
|
||||
/**
|
||||
* Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
|
||||
@@ -73,6 +76,9 @@ public final class FileNameEditorPane {
|
||||
sectionTitle.setStyle("-fx-font-weight: bold;");
|
||||
|
||||
textField.setId("filename-editor-text-field");
|
||||
Tooltip textFieldTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_TEXTFELD);
|
||||
textFieldTooltip.setShowDelay(Duration.millis(300));
|
||||
textField.setTooltip(textFieldTooltip);
|
||||
HBox.setHgrow(textField, Priority.ALWAYS);
|
||||
|
||||
HBox inputRow = new HBox(4, textField);
|
||||
@@ -86,9 +92,15 @@ public final class FileNameEditorPane {
|
||||
|
||||
saveButton.setId("filename-editor-save-button");
|
||||
saveButton.setOnAction(e -> fireSaveRequest());
|
||||
Tooltip saveTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
|
||||
saveTooltip.setShowDelay(Duration.millis(300));
|
||||
saveButton.setTooltip(saveTooltip);
|
||||
|
||||
resetButton.setId("filename-editor-reset-button");
|
||||
resetButton.setOnAction(e -> resetToAiProposal());
|
||||
Tooltip resetTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
|
||||
resetTooltip.setShowDelay(Duration.millis(300));
|
||||
resetButton.setTooltip(resetTooltip);
|
||||
|
||||
HBox buttonRow = new HBox(8, saveButton, resetButton);
|
||||
buttonRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
+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.ResetDocumentStatusResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Alert;
|
||||
|
||||
/**
|
||||
* Coordinates a single batch run (regular or targeted mini-run) triggered from the
|
||||
@@ -60,6 +63,9 @@ import javafx.application.Platform;
|
||||
* </ol>
|
||||
*/
|
||||
public final class GuiBatchRunCoordinator {
|
||||
private static final String CONFIG_FILE_NOT_NULL = "configFilePath must not be null";
|
||||
|
||||
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
|
||||
private static final String WORKER_THREAD_NAME = "gui-batch-run";
|
||||
@@ -115,6 +121,7 @@ public final class GuiBatchRunCoordinator {
|
||||
private final Consumer<Runnable> fxDispatcher;
|
||||
private final Listener listener;
|
||||
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
||||
private final Optional<ConfigurationFileLockPort> configurationFileLockPort;
|
||||
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
|
||||
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
|
||||
|
||||
@@ -176,6 +183,33 @@ public final class GuiBatchRunCoordinator {
|
||||
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the coordinator with all ports and the configuration file lock port, using
|
||||
* the default worker-thread factory and JavaFX Application Thread dispatcher.
|
||||
* <p>
|
||||
* This constructor is intended for production wiring in {@code GuiBatchRunTab} where
|
||||
* the lock port is supplied by Bootstrap.
|
||||
*
|
||||
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||
* not be null
|
||||
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||
* @param historicalDocumentContextPort port for resolving historical context; must not be null
|
||||
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
|
||||
* acquired before each run; {@code null} is treated as empty
|
||||
*/
|
||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||
GuiMiniRunLauncher miniRunLauncher,
|
||||
GuiResetDocumentStatusPort resetPort,
|
||||
Listener listener,
|
||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
|
||||
this(launcher, miniRunLauncher, resetPort,
|
||||
defaultThreadFactory(), defaultFxDispatcher(), listener,
|
||||
historicalDocumentContextPort, configurationFileLockPort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the coordinator with custom hooks for the worker-thread factory and the
|
||||
* UI-thread dispatcher.
|
||||
@@ -205,8 +239,8 @@ public final class GuiBatchRunCoordinator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the coordinator with all ports, custom thread factory, FX dispatcher and
|
||||
* historical file name port.
|
||||
* Creates the coordinator with all ports, custom thread factory, FX dispatcher,
|
||||
* historical file name port, and an optional configuration file lock port.
|
||||
* <p>
|
||||
* This is the canonical constructor. All other constructors delegate here.
|
||||
*
|
||||
@@ -221,6 +255,47 @@ public final class GuiBatchRunCoordinator {
|
||||
* @param listener GUI listener; must not be null
|
||||
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
|
||||
* skipped documents; must not be null
|
||||
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
|
||||
* acquired before each run and released in a finally block;
|
||||
* {@code null} is treated as empty
|
||||
*/
|
||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||
GuiMiniRunLauncher miniRunLauncher,
|
||||
GuiResetDocumentStatusPort resetPort,
|
||||
Function<Runnable, Thread> threadFactory,
|
||||
Consumer<Runnable> fxDispatcher,
|
||||
Listener listener,
|
||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
|
||||
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
|
||||
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
|
||||
this.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
|
||||
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
|
||||
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
|
||||
this.listener = Objects.requireNonNull(listener, "listener must not be null");
|
||||
this.historicalDocumentContextPort = Objects.requireNonNull(
|
||||
historicalDocumentContextPort, "historicalDocumentContextPort must not be null");
|
||||
this.configurationFileLockPort =
|
||||
Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor that omits the configuration file lock port.
|
||||
* <p>
|
||||
* Preserves existing callers that were written before the lock port was added.
|
||||
* Delegates to the canonical constructor with {@code configurationFileLockPort} empty.
|
||||
*
|
||||
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||
* not be null
|
||||
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||
* be null
|
||||
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
|
||||
* Thread; must not be null
|
||||
* @param listener GUI listener; must not be null
|
||||
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
|
||||
* skipped documents; must not be null
|
||||
*/
|
||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||
GuiMiniRunLauncher miniRunLauncher,
|
||||
@@ -229,14 +304,8 @@ public final class GuiBatchRunCoordinator {
|
||||
Consumer<Runnable> fxDispatcher,
|
||||
Listener listener,
|
||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
||||
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(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
|
||||
historicalDocumentContextPort, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,7 +356,7 @@ public final class GuiBatchRunCoordinator {
|
||||
* @throws NullPointerException if {@code configFilePath} is {@code null}
|
||||
*/
|
||||
public boolean start(Path configFilePath) {
|
||||
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
}
|
||||
@@ -313,7 +382,7 @@ public final class GuiBatchRunCoordinator {
|
||||
*/
|
||||
public boolean startMiniRun(Path configFilePath,
|
||||
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");
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
@@ -345,7 +414,7 @@ public final class GuiBatchRunCoordinator {
|
||||
*/
|
||||
public boolean startReprocessing(Path configFilePath,
|
||||
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");
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
@@ -386,7 +455,7 @@ public final class GuiBatchRunCoordinator {
|
||||
* @throws NullPointerException if any argument is {@code null}
|
||||
*/
|
||||
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");
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
@@ -437,6 +506,21 @@ public final class GuiBatchRunCoordinator {
|
||||
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
|
||||
configFilePath);
|
||||
observerSummary.set(null);
|
||||
|
||||
if (configurationFileLockPort.isPresent()) {
|
||||
try {
|
||||
configurationFileLockPort.get().acquireLock();
|
||||
} catch (ConfigurationFileLockException e) {
|
||||
LOG.warn("GUI-Verarbeitungslauf: Konfigurationsdatei gesperrt – Lauf abgebrochen: {}",
|
||||
e.getMessage());
|
||||
fxDispatcher.accept(() -> showLockErrorAlert());
|
||||
finishRun(GuiBatchRunLaunchOutcome.rejected(
|
||||
"Konfigurationsdatei gesperrt – Lauf wurde abgebrochen."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
|
||||
BatchRunCancellationToken token = cancellationRequested::get;
|
||||
GuiBatchRunLaunchOutcome outcome;
|
||||
@@ -454,12 +538,30 @@ public final class GuiBatchRunCoordinator {
|
||||
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||
}
|
||||
finishRun(outcome);
|
||||
} finally {
|
||||
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
|
||||
}
|
||||
}
|
||||
|
||||
private void executeMiniRun(Path configFilePath, Set<DocumentFingerprint> fingerprintFilter) {
|
||||
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
|
||||
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
|
||||
observerSummary.set(null);
|
||||
|
||||
if (configurationFileLockPort.isPresent()) {
|
||||
try {
|
||||
configurationFileLockPort.get().acquireLock();
|
||||
} catch (ConfigurationFileLockException e) {
|
||||
LOG.warn("GUI-Mini-Verarbeitungslauf: Konfigurationsdatei gesperrt – Lauf abgebrochen: {}",
|
||||
e.getMessage());
|
||||
fxDispatcher.accept(() -> showLockErrorAlert());
|
||||
finishRun(GuiBatchRunLaunchOutcome.rejected(
|
||||
"Konfigurationsdatei gesperrt – Mini-Lauf wurde abgebrochen."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
|
||||
BatchRunCancellationToken token = cancellationRequested::get;
|
||||
GuiBatchRunLaunchOutcome outcome;
|
||||
@@ -477,6 +579,9 @@ public final class GuiBatchRunCoordinator {
|
||||
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||
}
|
||||
finishRun(outcome);
|
||||
} finally {
|
||||
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
|
||||
}
|
||||
}
|
||||
|
||||
private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
||||
@@ -611,6 +716,19 @@ public final class GuiBatchRunCoordinator {
|
||||
historicalContext);
|
||||
}
|
||||
|
||||
private static void showLockErrorAlert() {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setTitle("Verarbeitungslauf nicht möglich");
|
||||
alert.setHeaderText("Konfigurationsdatei gesperrt");
|
||||
alert.setContentText(
|
||||
"Der Verarbeitungslauf konnte nicht gestartet werden, da die "
|
||||
+ "Konfigurationsdatei nicht gesperrt werden konnte.\n\n"
|
||||
+ "Mögliche Ursache: Der automatische Scheduler ist aktiv oder "
|
||||
+ "ein anderer Prozess hält die Datei belegt.\n\n"
|
||||
+ "Bitte stoppen Sie den Scheduler und versuchen Sie es erneut.");
|
||||
alert.showAndWait();
|
||||
}
|
||||
|
||||
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
|
||||
return (configPath, fingerprint) -> Optional.empty();
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ public record GuiBatchRunLaunchOutcome(
|
||||
* Compact constructor normalising the failure message holder.
|
||||
*/
|
||||
public GuiBatchRunLaunchOutcome {
|
||||
failureMessage = failureMessage == null ? Optional.empty() : failureMessage;
|
||||
failureMessage = Objects.requireNonNullElse(failureMessage, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+53
-20
@@ -70,7 +70,7 @@ public record GuiBatchRunResultRow(
|
||||
/**
|
||||
* Icon shown in the status column when a document's persistence status has been reset.
|
||||
*/
|
||||
static final String RESET_PENDING_ICON = "\u27F3"; // ⟳ CLOCKWISE GAPPED CIRCLE ARROW
|
||||
static final String RESET_PENDING_ICON = "⟳"; // ⟳ CLOCKWISE GAPPED CIRCLE ARROW
|
||||
|
||||
/**
|
||||
* Compact constructor normalising optional holders and validating mandatory fields.
|
||||
@@ -88,16 +88,16 @@ public record GuiBatchRunResultRow(
|
||||
}
|
||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||
Objects.requireNonNull(status, "status must not be null");
|
||||
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
|
||||
correctedFileName = correctedFileName == null ? Optional.empty() : correctedFileName;
|
||||
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
|
||||
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
|
||||
aiFailureMessage = aiFailureMessage == null ? Optional.empty() : aiFailureMessage;
|
||||
finalFileName = Objects.requireNonNullElse(finalFileName, Optional.empty());
|
||||
correctedFileName = Objects.requireNonNullElse(correctedFileName, Optional.empty());
|
||||
resolvedDate = Objects.requireNonNullElse(resolvedDate, Optional.empty());
|
||||
aiReasoning = Objects.requireNonNullElse(aiReasoning, Optional.empty());
|
||||
aiFailureMessage = Objects.requireNonNullElse(aiFailureMessage, Optional.empty());
|
||||
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
||||
if (processingDuration.isNegative()) {
|
||||
throw new IllegalArgumentException("processingDuration must not be negative");
|
||||
}
|
||||
historicalContext = historicalContext == null ? Optional.empty() : historicalContext;
|
||||
historicalContext = Objects.requireNonNullElse(historicalContext, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,25 +192,58 @@ public record GuiBatchRunResultRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the status icon for this row as a Unicode character that renders reliably
|
||||
* in JavaFX on Windows.
|
||||
* Gibt das Status-Icon für diese Zeile als Unicode-Zeichen zurück, das in JavaFX
|
||||
* unter Windows zuverlässig dargestellt wird (16px, bold).
|
||||
* <p>
|
||||
* When {@code resetPending} is {@code true} the reset icon is returned regardless of
|
||||
* the underlying status.
|
||||
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
||||
* eigentlichen Status das Reset-Icon zurückgegeben.
|
||||
* <p>
|
||||
* Die Icon-Werte stammen aus {@link ProcessingStatusPresentation}.
|
||||
*
|
||||
* @return the corresponding status character
|
||||
* @return das entsprechende Status-Zeichen
|
||||
*/
|
||||
public String statusIcon() {
|
||||
if (resetPending) {
|
||||
return RESET_PENDING_ICON;
|
||||
}
|
||||
return switch (status) {
|
||||
case SUCCESS -> "\u2714"; // ✔ HEAVY CHECK MARK
|
||||
case FAILED_RETRYABLE -> "\u26A0"; // ⚠ WARNING SIGN
|
||||
case FAILED_PERMANENT -> "\u2718"; // ✘ HEAVY BALLOT X
|
||||
case SKIPPED_ALREADY_PROCESSED,
|
||||
SKIPPED_FINAL_FAILURE -> "\u25BA"; // ► BLACK RIGHT-POINTING POINTER
|
||||
};
|
||||
return ProcessingStatusPresentation.iconFor(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die CSS-Farbe für das Status-Icon dieser Zeile zurück.
|
||||
* <p>
|
||||
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
||||
* eigentlichen Status die Reset-Farbe zurückgegeben.
|
||||
* <p>
|
||||
* Farbe ist niemals das einzige Unterscheidungsmerkmal – {@link #statusIcon()} und
|
||||
* {@link #statusTooltip()} beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
|
||||
* Die Farbwerte stammen aus {@link ProcessingStatusPresentation}.
|
||||
*
|
||||
* @return die entsprechende CSS-Hex-Farbe (z. B. {@code "#2e7d32"})
|
||||
*/
|
||||
public String statusColor() {
|
||||
if (resetPending) {
|
||||
return "#757575"; // Grau für Reset-pending
|
||||
}
|
||||
return ProcessingStatusPresentation.cssColorFor(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den deutschsprachigen Tooltip-Text für den Verarbeitungsstatus dieser Zeile zurück.
|
||||
* <p>
|
||||
* Wenn {@code resetPending} den Wert {@code true} hat, wird ein Tooltip für den
|
||||
* Reset-Zustand zurückgegeben.
|
||||
* <p>
|
||||
* Der Tooltip-Text beschreibt den Status vollständig ohne Farbe. Die Texte stammen
|
||||
* aus {@link ProcessingStatusPresentation}.
|
||||
*
|
||||
* @return der Tooltip-Text; nie leer
|
||||
*/
|
||||
public String statusTooltip() {
|
||||
if (resetPending) {
|
||||
return RESET_PENDING_LABEL;
|
||||
}
|
||||
return ProcessingStatusPresentation.tooltipFor(status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,7 +261,7 @@ public record GuiBatchRunResultRow(
|
||||
return switch (status) {
|
||||
case SUCCESS -> "Erfolgreich";
|
||||
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
|
||||
case FAILED_PERMANENT -> "Fehlgeschlagen (permanent)";
|
||||
case FAILED_PERMANENT -> "Fehlgeschlagen (dauerhaft)";
|
||||
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen (bereits verarbeitet)";
|
||||
case SKIPPED_FINAL_FAILURE -> "Übersprungen (endgültig fehlgeschlagen)";
|
||||
};
|
||||
|
||||
+221
-85
@@ -4,10 +4,10 @@ import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -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.ResetDocumentStatusResult;
|
||||
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.RunId;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||
@@ -67,6 +70,7 @@ import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableRow;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
@@ -107,6 +111,11 @@ import javafx.scene.layout.VBox;
|
||||
* dafür, Hintergrundereignisse vor dem Callback auf den FX-Thread zu übertragen.
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -192,19 +201,24 @@ public final class GuiBatchRunTab {
|
||||
private final Button resetStatusButton = new Button("Status zurücksetzen");
|
||||
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. */
|
||||
private final FileNameEditorPane fileNameEditor = new FileNameEditorPane();
|
||||
|
||||
/** PDF-Vorschau-Komponente im Detailbereich. */
|
||||
private final PdfPreviewPane pdfPreview = new PdfPreviewPane();
|
||||
|
||||
/** Summary-Banner unterhalb des Fortschrittsbalkens – sichtbar nach Laufabschluss. */
|
||||
private final BatchRunSummaryBanner summaryBanner = new BatchRunSummaryBanner();
|
||||
|
||||
private final Supplier<Path> configPathSupplier;
|
||||
private final BooleanSupplier savedConfigurationReadyCheck;
|
||||
private final Runnable onRunStateChanged;
|
||||
private final GuiBatchRunCoordinator coordinator;
|
||||
private final Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier;
|
||||
private final Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier;
|
||||
private final Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier;
|
||||
private final Supplier<Optional<Path>> sourceFolderSupplier;
|
||||
private final Supplier<Optional<String>> targetFolderSupplier;
|
||||
|
||||
@@ -227,7 +241,8 @@ public final class GuiBatchRunTab {
|
||||
|
||||
/**
|
||||
* 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;
|
||||
* darf nicht null sein
|
||||
@@ -252,6 +267,9 @@ public final class GuiBatchRunTab {
|
||||
* darf leeres Optional zurückliefern
|
||||
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner als
|
||||
* 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,
|
||||
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
|
||||
@@ -263,7 +281,8 @@ public final class GuiBatchRunTab {
|
||||
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
|
||||
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
|
||||
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(miniRunLauncherSupplier, "miniRunLauncherSupplier must not be null");
|
||||
Objects.requireNonNull(resetPortSupplier, "resetPortSupplier must not be null");
|
||||
@@ -275,13 +294,15 @@ public final class GuiBatchRunTab {
|
||||
manualFileRenamePortSupplier, "manualFileRenamePortSupplier must not be null");
|
||||
this.manualFileCopyPortSupplier = Objects.requireNonNull(
|
||||
manualFileCopyPortSupplier, "manualFileCopyPortSupplier must not be null");
|
||||
this.historicalDocumentContextPortSupplier = Objects.requireNonNull(
|
||||
Objects.requireNonNull(
|
||||
historicalDocumentContextPortSupplier, "historicalDocumentContextPortSupplier must not be null");
|
||||
this.sourceFolderSupplier = Objects.requireNonNull(
|
||||
sourceFolderSupplier, "sourceFolderSupplier must not be null");
|
||||
this.targetFolderSupplier = Objects.requireNonNull(
|
||||
targetFolderSupplier, "targetFolderSupplier must not be null");
|
||||
|
||||
Optional<ConfigurationFileLockPort> effectiveLockPort =
|
||||
Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
|
||||
this.coordinator = new GuiBatchRunCoordinator(
|
||||
(configPath, observer, token) ->
|
||||
launcherSupplier.get().launch(configPath, observer, token),
|
||||
@@ -290,7 +311,8 @@ public final class GuiBatchRunTab {
|
||||
(configPath, fingerprints) ->
|
||||
resetPortSupplier.get().reset(configPath, fingerprints),
|
||||
new CoordinatorListener(),
|
||||
historicalDocumentContextPortSupplier.get());
|
||||
historicalDocumentContextPortSupplier.get(),
|
||||
effectiveLockPort);
|
||||
|
||||
this.tab.setClosable(false);
|
||||
this.tab.setContent(buildContent());
|
||||
@@ -309,6 +331,51 @@ public final class GuiBatchRunTab {
|
||||
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.
|
||||
*
|
||||
@@ -434,6 +501,25 @@ public final class GuiBatchRunTab {
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -501,8 +587,14 @@ public final class GuiBatchRunTab {
|
||||
HBox.setHgrow(progressBar, Priority.ALWAYS);
|
||||
|
||||
counterLabel.setId("batch-run-counter");
|
||||
HBox header = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
|
||||
header.setAlignment(Pos.CENTER_LEFT);
|
||||
HBox progressRow = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
|
||||
progressRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
// Summary-Banner unterhalb des Fortschrittsbalkens, oberhalb der Tabelle
|
||||
HBox bannerNode = summaryBanner.getNode();
|
||||
bannerNode.setId("batch-run-summary-banner");
|
||||
|
||||
VBox header = new VBox(0, progressRow, bannerNode);
|
||||
header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0));
|
||||
return header;
|
||||
}
|
||||
@@ -518,19 +610,22 @@ public final class GuiBatchRunTab {
|
||||
// Selektions-Aktions-Buttons unterhalb der Tabelle (linke Spalte)
|
||||
reprocessButton.setId("batch-run-reprocess");
|
||||
reprocessButton.setOnAction(event -> handleReprocessSelected());
|
||||
reprocessButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ERNEUT_VERARBEITEN));
|
||||
|
||||
resetStatusButton.setId("batch-run-reset-status");
|
||||
resetStatusButton.setOnAction(event -> handleResetSelected());
|
||||
resetStatusButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STATUS_ZURUECKSETZEN));
|
||||
|
||||
HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton);
|
||||
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)
|
||||
messageArea.setId("batch-run-message-area");
|
||||
messageArea.setEditable(false);
|
||||
messageArea.setWrapText(true);
|
||||
messageArea.setPrefRowCount(3);
|
||||
messageArea.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MESSAGE_AREA));
|
||||
// Hinweisbereich erst einblenden wenn eine Meldung vorliegt
|
||||
messageArea.setVisible(false);
|
||||
messageArea.setManaged(false);
|
||||
@@ -591,12 +686,14 @@ public final class GuiBatchRunTab {
|
||||
|
||||
masterCheckBox.setId("batch-run-master-checkbox");
|
||||
masterCheckBox.setOnAction(e -> handleMasterCheckBoxAction());
|
||||
masterCheckBox.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MASTER_CHECKBOX));
|
||||
checkboxCol.setGraphic(masterCheckBox);
|
||||
|
||||
checkboxCol.setCellFactory(col -> new CheckBoxCell());
|
||||
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.setPrefWidth(64);
|
||||
iconCol.setCellFactory(col -> new TableCell<GuiBatchRunResultRow, String>() {
|
||||
@@ -606,6 +703,7 @@ public final class GuiBatchRunTab {
|
||||
if (empty || icon == null) {
|
||||
setText(null);
|
||||
setStyle(null);
|
||||
setTooltip(null);
|
||||
return;
|
||||
}
|
||||
setText(icon);
|
||||
@@ -613,18 +711,26 @@ public final class GuiBatchRunTab {
|
||||
GuiBatchRunResultRow data = tableRow != null ? tableRow.getItem() : null;
|
||||
if (data != null && data.resetPending()) {
|
||||
setStyle("-fx-text-fill: #1565c0; -fx-alignment: CENTER; -fx-font-size: 14;");
|
||||
} else {
|
||||
String color = data != null ? statusColor(data.status()) : "#000000";
|
||||
setTooltip(new Tooltip(data.statusTooltip()));
|
||||
} else if (data != null) {
|
||||
// Farbe aus zentralem Mapping – nie alleiniges Unterscheidungsmerkmal
|
||||
String color = ProcessingStatusPresentation.cssColorFor(data.status());
|
||||
setStyle("-fx-text-fill: " + color + "; -fx-alignment: CENTER; -fx-font-size: 14;");
|
||||
setTooltip(new Tooltip(data.statusTooltip()));
|
||||
} else {
|
||||
setStyle("-fx-alignment: CENTER; -fx-font-size: 14;");
|
||||
setTooltip(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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.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 -> {
|
||||
GuiBatchRunResultRow row = data.getValue();
|
||||
if (row.resetPending()) {
|
||||
@@ -634,14 +740,16 @@ public final class GuiBatchRunTab {
|
||||
});
|
||||
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(
|
||||
data.getValue().resolvedDate()
|
||||
.map(DateTimeFormatter.ISO_LOCAL_DATE::format)
|
||||
.orElse(EMPTY_CELL_TEXT)));
|
||||
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(
|
||||
formatDuration(data.getValue().processingDuration())));
|
||||
durationCol.setPrefWidth(80);
|
||||
@@ -654,8 +762,8 @@ public final class GuiBatchRunTab {
|
||||
}
|
||||
});
|
||||
|
||||
resultTable.getColumns().setAll(
|
||||
checkboxCol, iconCol, nameCol, newNameCol, dateCol, durationCol);
|
||||
resultTable.getColumns().setAll(List.of(
|
||||
checkboxCol, iconCol, nameCol, newNameCol, dateCol, durationCol));
|
||||
|
||||
// Selektion im TableView synchronisiert selectedRows und Checkboxen.
|
||||
resultTable.getSelectionModel().getSelectedItems().addListener(
|
||||
@@ -717,7 +825,7 @@ public final class GuiBatchRunTab {
|
||||
return;
|
||||
}
|
||||
fileNameEditor.discardChanges();
|
||||
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung – Benutzer hat verworfen");
|
||||
LOG.debug(DIRTY_STATE_MSG);
|
||||
}
|
||||
|
||||
// Neue Zeile laden
|
||||
@@ -854,16 +962,35 @@ public final class GuiBatchRunTab {
|
||||
*/
|
||||
private void handleCopyResult(ManualFileCopyResult result, GuiBatchRunResultRow row) {
|
||||
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: {})",
|
||||
row.originalFileName(), success.appliedFileName(),
|
||||
success.conflictSuffixApplied());
|
||||
row.originalFileName(), success.appliedFileName(), success.conflictSuffixApplied());
|
||||
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, success.appliedFileName());
|
||||
currentlySelectedRow = updatedRow;
|
||||
fileNameEditor.clearDirtyState();
|
||||
upsertResultRowByFingerprint(updatedRow);
|
||||
String targetFolder = targetFolderSupplier.get().orElse("");
|
||||
fileNameEditor.loadSelection(updatedRow, targetFolder);
|
||||
fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
|
||||
String msg = "Datei kopiert und gespeichert: " + success.appliedFileName();
|
||||
if (success.conflictSuffixApplied()) {
|
||||
msg += " (Suffix wegen Namenskonflikt angehängt)";
|
||||
@@ -871,37 +998,18 @@ public final class GuiBatchRunTab {
|
||||
showMessage(msg);
|
||||
refreshAggregateCountersFromItems();
|
||||
}
|
||||
case ManualFileCopyNoOpIdenticalTarget noOp -> {
|
||||
|
||||
private void applyCopyNoOpIdentical(ManualFileCopyNoOpIdenticalTarget noOp, GuiBatchRunResultRow row) {
|
||||
LOG.info("Manuelle Dateikopie: identische Zieldatei {} bereits vorhanden – kein Schreibvorgang.",
|
||||
noOp.existingFileName());
|
||||
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, noOp.existingFileName());
|
||||
currentlySelectedRow = updatedRow;
|
||||
fileNameEditor.clearDirtyState();
|
||||
upsertResultRowByFingerprint(updatedRow);
|
||||
String targetFolder = targetFolderSupplier.get().orElse("");
|
||||
fileNameEditor.loadSelection(updatedRow, targetFolder);
|
||||
fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
|
||||
showMessage("Identische Datei bereits vorhanden – Status auf SUCCESS gesetzt");
|
||||
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
|
||||
@@ -1002,24 +1110,24 @@ public final class GuiBatchRunTab {
|
||||
noOp.existingFileName());
|
||||
}
|
||||
case ManualFileRenameDocumentNotFound notFound -> {
|
||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", notFound.reason());
|
||||
LOG.warn(RENAME_FAILED_LOG, notFound.reason());
|
||||
showMessage("Fehler: Dokument nicht gefunden – " + notFound.reason());
|
||||
}
|
||||
case ManualFileRenameInvalidState invalidState -> {
|
||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", invalidState.reason());
|
||||
LOG.warn(RENAME_FAILED_LOG, invalidState.reason());
|
||||
showMessage("Fehler: Ungültiger Dokumentstatus – " + invalidState.reason());
|
||||
}
|
||||
case ManualFileRenameSourceFileMissing sourceMissing -> {
|
||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}",
|
||||
LOG.warn(RENAME_FAILED_LOG,
|
||||
sourceMissing.expectedFileName());
|
||||
showMessage("Zieldatei nicht gefunden – Umbenennung nicht möglich");
|
||||
}
|
||||
case ManualFileRenameFileSystemFailure fsFail -> {
|
||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", fsFail.message());
|
||||
LOG.warn(RENAME_FAILED_LOG, fsFail.message());
|
||||
showMessage("Dateisystemfehler: " + fsFail.message());
|
||||
}
|
||||
case ManualFileRenamePersistenceFailure persistFail -> {
|
||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", persistFail.message());
|
||||
LOG.warn(RENAME_FAILED_LOG, persistFail.message());
|
||||
showMessage("Persistenzfehler (Dateisystem wurde zurückgerollt): "
|
||||
+ persistFail.message());
|
||||
}
|
||||
@@ -1129,14 +1237,16 @@ public final class GuiBatchRunTab {
|
||||
// Lauf-Steuerungs-Buttons
|
||||
startButton.setId("batch-run-start");
|
||||
startButton.setOnAction(event -> handleStart());
|
||||
startButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STARTEN));
|
||||
|
||||
cancelButton.setId("batch-run-cancel");
|
||||
cancelButton.setOnAction(event -> requestCancellation());
|
||||
cancelButton.setDisable(true);
|
||||
cancelButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ABBRECHEN));
|
||||
|
||||
HBox runButtonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
|
||||
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;
|
||||
}
|
||||
@@ -1158,7 +1268,7 @@ public final class GuiBatchRunTab {
|
||||
return;
|
||||
}
|
||||
fileNameEditor.discardChanges();
|
||||
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung – Benutzer hat verworfen");
|
||||
LOG.debug(DIRTY_STATE_MSG);
|
||||
}
|
||||
if (!savedConfigurationReadyCheck.getAsBoolean()) {
|
||||
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||
@@ -1180,6 +1290,7 @@ public final class GuiBatchRunTab {
|
||||
messageArea.setVisible(false);
|
||||
messageArea.setManaged(false);
|
||||
messageArea.setStyle(null);
|
||||
summaryBanner.clear();
|
||||
resetMetrics();
|
||||
updateCounterLabel();
|
||||
progressBar.setProgress(0);
|
||||
@@ -1211,7 +1322,7 @@ public final class GuiBatchRunTab {
|
||||
return;
|
||||
}
|
||||
fileNameEditor.discardChanges();
|
||||
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung – Benutzer hat verworfen");
|
||||
LOG.debug(DIRTY_STATE_MSG);
|
||||
}
|
||||
if (!savedConfigurationReadyCheck.getAsBoolean()) {
|
||||
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||
@@ -1386,7 +1497,7 @@ public final class GuiBatchRunTab {
|
||||
|
||||
private void updateButtonStates() {
|
||||
boolean running = runningProperty.get();
|
||||
startButton.setDisable(running);
|
||||
startButton.setDisable(running || schedulerActive);
|
||||
if (!running) {
|
||||
cancelButton.setDisable(true);
|
||||
} else {
|
||||
@@ -1420,13 +1531,21 @@ public final class GuiBatchRunTab {
|
||||
// Statische Helfer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static String statusColor(DocumentCompletionStatus status) {
|
||||
return switch (status) {
|
||||
case SUCCESS -> "#2e7d32";
|
||||
case FAILED_RETRYABLE -> "#e65100";
|
||||
case FAILED_PERMANENT -> "#c62828";
|
||||
case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> "#757575";
|
||||
};
|
||||
// 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) {
|
||||
@@ -1448,31 +1567,16 @@ public final class GuiBatchRunTab {
|
||||
return builder.toString();
|
||||
}
|
||||
if (row.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED) {
|
||||
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();
|
||||
return appendSkippedAlreadyProcessed(builder, row);
|
||||
}
|
||||
if (row.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE) {
|
||||
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 appendSkippedFinalFailure(builder, row);
|
||||
}
|
||||
if (row.status() == DocumentCompletionStatus.FAILED_PERMANENT) {
|
||||
builder.append('\n').append(ProcessingStatusPresentation.DETAIL_TEXT_FAILED_PERMANENT);
|
||||
row.aiFailureMessage().ifPresent(msg ->
|
||||
builder.append("\n\nFehlerdetail: ")
|
||||
.append(AiFailureMessageTranslator.translate(msg)));
|
||||
return builder.toString();
|
||||
}
|
||||
row.effectiveFileName()
|
||||
@@ -1489,6 +1593,34 @@ public final class GuiBatchRunTab {
|
||||
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(
|
||||
Path p, Set<DocumentFingerprint> f,
|
||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver o,
|
||||
@@ -1572,6 +1704,10 @@ public final class GuiBatchRunTab {
|
||||
miniRunCompletedFingerprints = new HashSet<>();
|
||||
}
|
||||
selectedRows.clear();
|
||||
// Summary-Banner aus der aktuellen Ergebnisliste aggregieren und anzeigen
|
||||
Map<DocumentCompletionStatus, Integer> counts =
|
||||
BatchRunSummaryBanner.aggregateCounts(resultItems);
|
||||
summaryBanner.update(counts);
|
||||
appendSummary(outcome);
|
||||
updateButtonStates();
|
||||
notifyRunStateChanged();
|
||||
|
||||
+342
-23
@@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -19,13 +20,21 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.embed.swing.SwingFXUtils;
|
||||
import javafx.geometry.Bounds;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.util.Duration;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.input.ScrollEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
@@ -36,10 +45,21 @@ import javafx.scene.layout.VBox;
|
||||
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
|
||||
*
|
||||
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
|
||||
* in einer {@link ImageView} an. Die Anzeige ist vollständig eingepasst (fit-to-view):
|
||||
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} sind an die Größe des
|
||||
* in einer {@link ImageView} an. Im Fit-to-View-Modus (Standardzustand) sind
|
||||
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} an die Größe des
|
||||
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
|
||||
* Seitenverhältnis. 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
|
||||
* 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. */
|
||||
private static final float RENDER_DPI = 120f;
|
||||
|
||||
/** Minimaler Zoomfaktor (10 %). */
|
||||
static final double ZOOM_MIN = 0.10;
|
||||
|
||||
/** Maximaler Zoomfaktor (500 %). */
|
||||
static final double ZOOM_MAX = 5.00;
|
||||
|
||||
/** Zoom-Schrittgröße pro Mausrad-Raste (10 %). */
|
||||
private static final double ZOOM_STEP = 0.10;
|
||||
|
||||
/** Typischer vertikaler Scroll-Delta pro Mausrad-Raste. */
|
||||
private static final double ZOOM_NOTCH_THRESHOLD = 40.0;
|
||||
|
||||
private final VBox root = new VBox(4);
|
||||
private final StackPane viewStack = new StackPane();
|
||||
private final ImageView imageView = new ImageView();
|
||||
@@ -86,6 +118,35 @@ public final class PdfPreviewPane {
|
||||
private final Button prevButton = new Button("◀ Vorherige");
|
||||
private final Button nextButton = new Button("Nächste ▶");
|
||||
private final Label sectionTitle = new Label("PDF-Vorschau");
|
||||
private final ScrollPane scrollPane = new ScrollPane(viewStack);
|
||||
|
||||
/** Aktueller Zoomfaktor; 1.0 entspricht der natürlichen Viewport-Breite. */
|
||||
private double zoomLevel = 1.0;
|
||||
|
||||
/** Akkumulator für sub-Rasten-Scroll-Deltas. */
|
||||
private double zoomAccumulator = 0.0;
|
||||
|
||||
/**
|
||||
* Referenzbreite für die manuelle Zoom-Skalierung; gilt
|
||||
* {@code imageView.fitWidth = naturalViewportWidth × zoomLevel} im manuellen
|
||||
* Zoom-Modus. Beim Verlassen des Fit-Modus wird der Wert auf die natürliche
|
||||
* Bildbreite gesetzt, sodass {@code zoomLevel = 1.0} der pixel-genauen
|
||||
* Originalgröße entspricht und {@code zoomLevel} damit gleich dem visuellen
|
||||
* Skalierungsfaktor ist. {@code 0.0} bedeutet Fit-to-View-Modus ist aktiv.
|
||||
*/
|
||||
private double naturalViewportWidth = 0.0;
|
||||
|
||||
/** X-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
|
||||
private double panStartX = -1;
|
||||
|
||||
/** Y-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
|
||||
private double panStartY = -1;
|
||||
|
||||
/** Horizontaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
|
||||
private double panStartHvalue = 0.0;
|
||||
|
||||
/** Vertikaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
|
||||
private double panStartVvalue = 0.0;
|
||||
|
||||
/**
|
||||
* Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
|
||||
@@ -110,24 +171,24 @@ public final class PdfPreviewPane {
|
||||
|
||||
/**
|
||||
* 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 PDDocument currentDocument = null;
|
||||
private final AtomicReference<PDDocument> currentDocument = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* 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 PDFRenderer currentRenderer = null;
|
||||
private final AtomicReference<PDFRenderer> currentRenderer = new AtomicReference<>();
|
||||
|
||||
/** Aktuell geladene Quelldatei; null wenn keine Selektion vorliegt. */
|
||||
private Path currentSourceFile = null;
|
||||
/** Aktuell geladene Quelldatei; leerer Referenzwert wenn keine Selektion vorliegt. */
|
||||
private final AtomicReference<Path> currentSourceFile = new AtomicReference<>();
|
||||
|
||||
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
|
||||
private int currentPage = 0;
|
||||
private volatile int currentPage = 0;
|
||||
|
||||
/** Anzahl der Seiten der aktuell geladenen PDF; -1 wenn nicht ermittelt. */
|
||||
private int totalPages = -1;
|
||||
private volatile int totalPages = -1;
|
||||
|
||||
/** Gibt an ob die Navigation bedienbar ist. */
|
||||
private boolean enabled = true;
|
||||
@@ -162,13 +223,48 @@ public final class PdfPreviewPane {
|
||||
StackPane.setAlignment(imageView, Pos.CENTER);
|
||||
StackPane.setAlignment(overlayLabel, 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.setOnAction(e -> navigateToPreviousPage());
|
||||
Tooltip prevTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_VORHERIGE_SEITE);
|
||||
prevTooltip.setShowDelay(Duration.millis(300));
|
||||
prevButton.setTooltip(prevTooltip);
|
||||
|
||||
nextButton.setId("pdf-preview-next-button");
|
||||
nextButton.setOnAction(e -> navigateToNextPage());
|
||||
Tooltip nextTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_NAECHSTE_SEITE);
|
||||
nextTooltip.setShowDelay(Duration.millis(300));
|
||||
nextButton.setTooltip(nextTooltip);
|
||||
|
||||
pageLabel.setId("pdf-preview-page-label");
|
||||
pageLabel.setStyle("-fx-text-fill: #555555;");
|
||||
@@ -177,7 +273,7 @@ public final class PdfPreviewPane {
|
||||
navBar.setAlignment(Pos.CENTER);
|
||||
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));
|
||||
|
||||
showPlaceholder();
|
||||
@@ -208,10 +304,11 @@ public final class PdfPreviewPane {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
currentSourceFile = sourceFile;
|
||||
currentSourceFile.set(sourceFile);
|
||||
currentPage = 0;
|
||||
totalPages = -1;
|
||||
pageCache.clear();
|
||||
resetToFitView();
|
||||
requestLoad(sourceFile);
|
||||
}
|
||||
|
||||
@@ -222,7 +319,7 @@ public final class PdfPreviewPane {
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*/
|
||||
public void clear() {
|
||||
currentSourceFile = null;
|
||||
currentSourceFile.set(null);
|
||||
currentPage = 0;
|
||||
totalPages = -1;
|
||||
pageCache.clear();
|
||||
@@ -230,6 +327,7 @@ public final class PdfPreviewPane {
|
||||
currentRequestSequence.incrementAndGet();
|
||||
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
|
||||
executor.submit(this::closeCurrentDocumentOnWorker);
|
||||
resetToFitView();
|
||||
imageView.setImage(null);
|
||||
showPlaceholder();
|
||||
updateNavigationButtons();
|
||||
@@ -287,6 +385,16 @@ public final class PdfPreviewPane {
|
||||
return progressIndicator;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
ScrollPane scrollPane() {
|
||||
return scrollPane;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
double zoomLevel() {
|
||||
return zoomLevel;
|
||||
}
|
||||
|
||||
// --- Navigation -----------------------------------------------------------
|
||||
|
||||
private void navigateToPreviousPage() {
|
||||
@@ -365,12 +473,13 @@ public final class PdfPreviewPane {
|
||||
|
||||
try {
|
||||
PDDocument doc = Loader.loadPDF(ioFile);
|
||||
currentDocument = doc;
|
||||
currentRenderer = new PDFRenderer(doc);
|
||||
currentDocument.set(doc);
|
||||
PDFRenderer renderer = new PDFRenderer(doc);
|
||||
currentRenderer.set(renderer);
|
||||
|
||||
int pages = Math.max(1, doc.getNumberOfPages());
|
||||
BufferedImage buffered =
|
||||
currentRenderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
|
||||
renderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
|
||||
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
|
||||
|
||||
final int totalPagesFinal = pages;
|
||||
@@ -406,7 +515,7 @@ public final class PdfPreviewPane {
|
||||
* @param seq die Sequenznummer dieser Anforderung
|
||||
*/
|
||||
private void renderPageOnWorker(int page, long seq) {
|
||||
PDFRenderer renderer = currentRenderer;
|
||||
PDFRenderer renderer = currentRenderer.get();
|
||||
if (renderer == null) {
|
||||
// Dokument wurde zwischenzeitlich geschlossen – nichts zu tun
|
||||
return;
|
||||
@@ -435,9 +544,8 @@ public final class PdfPreviewPane {
|
||||
* auf dem Worker-Thread und ist idempotent.
|
||||
*/
|
||||
private void closeCurrentDocumentOnWorker() {
|
||||
PDDocument doc = currentDocument;
|
||||
currentDocument = null;
|
||||
currentRenderer = null;
|
||||
PDDocument doc = currentDocument.getAndSet(null);
|
||||
currentRenderer.set(null);
|
||||
if (doc != null) {
|
||||
try {
|
||||
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 ---------------------------------------------------
|
||||
|
||||
private void showPlaceholder() {
|
||||
@@ -506,7 +825,7 @@ public final class PdfPreviewPane {
|
||||
}
|
||||
|
||||
private void updateNavigationButtons() {
|
||||
boolean canNavigate = enabled && currentSourceFile != null && totalPages > 0;
|
||||
boolean canNavigate = enabled && currentSourceFile.get() != null && totalPages > 0;
|
||||
prevButton.setDisable(!canNavigate || currentPage <= 1);
|
||||
nextButton.setDisable(!canNavigate || currentPage >= totalPages);
|
||||
}
|
||||
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||
|
||||
/**
|
||||
* Zentrale Mapping-Klasse für die visuelle Darstellung von Verarbeitungsstatus in der GUI.
|
||||
* <p>
|
||||
* Diese Klasse ist die einzige autoritative Quelle für Status-Icons, CSS-Farben,
|
||||
* Tooltip-Texte und Summary-Kategorielabels aller {@link DocumentCompletionStatus}-Werte.
|
||||
* Alle Anzeigeorte im GUI-Adapter (Ergebnistabelle, Detailbereich, Summary-Banner)
|
||||
* beziehen ihre Darstellungsinformationen ausschließlich über diese Klasse.
|
||||
* <p>
|
||||
* Farbe ist niemals das einzige Unterscheidungsmerkmal: Icon und Tooltip-Text beschreiben
|
||||
* den Status vollständig auch ohne Farbwahrnehmung.
|
||||
* <p>
|
||||
* Diese Klasse enthält keine JavaFX-Typen; sie ist rein datenhaltend und zustandslos.
|
||||
* Alle Methoden sind statisch.
|
||||
*/
|
||||
public final class ProcessingStatusPresentation {
|
||||
private static final String STATUS_NOT_NULL = "status darf nicht null sein";
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Icons (Unicode-Zeichen, zuverlässig darstellbar unter Windows 10+)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Icon für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||
public static final String ICON_SUCCESS = "✓"; // CHECK MARK
|
||||
|
||||
/** Icon für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||
public static final String ICON_FAILED_RETRYABLE = "↻"; // CLOCKWISE OPEN CIRCLE ARROW
|
||||
|
||||
/** Icon für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||
public static final String ICON_FAILED_PERMANENT = "×"; // MULTIPLICATION SIGN
|
||||
|
||||
/** Icon für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||
public static final String ICON_SKIPPED_ALREADY_PROCESSED = "≡"; // IDENTICAL TO
|
||||
|
||||
/** Icon für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||
public static final String ICON_SKIPPED_FINAL_FAILURE = "⊘"; // CIRCLED DIVISION SLASH
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CSS-Farben (Hex-Strings für JavaFX setStyle)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** CSS-Farbe für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||
public static final String COLOR_SUCCESS = "#2e7d32"; // Grün
|
||||
|
||||
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||
public static final String COLOR_FAILED_RETRYABLE = "#d98200"; // Orange
|
||||
|
||||
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||
public static final String COLOR_FAILED_PERMANENT = "#c62828"; // Rot
|
||||
|
||||
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||
public static final String COLOR_SKIPPED_ALREADY_PROCESSED = "#757575"; // Grau
|
||||
|
||||
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||
public static final String COLOR_SKIPPED_FINAL_FAILURE = "#424242"; // Dunkelgrau
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tooltip-Texte (deutsche Benutzertexte, gemäß Spezifikation)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Tooltip für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||
public static final String TOOLTIP_SUCCESS =
|
||||
"Erfolgreich verarbeitet und umbenannt.";
|
||||
|
||||
/** Tooltip für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||
public static final String TOOLTIP_FAILED_RETRYABLE =
|
||||
"Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht.";
|
||||
|
||||
/** Tooltip für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||
public static final String TOOLTIP_FAILED_PERMANENT =
|
||||
"Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), Passwortschutz "
|
||||
+ "oder beschädigte Datei. Kein weiterer automatischer Versuch.";
|
||||
|
||||
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||
public static final String TOOLTIP_SKIPPED_ALREADY_PROCESSED =
|
||||
"Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
|
||||
|
||||
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||
public static final String TOOLTIP_SKIPPED_FINAL_FAILURE =
|
||||
"Endgültig übersprungen nach wiederholten Fehlern.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Detailtext für FAILED_PERMANENT (Erklärung im Detailbereich)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Erweiterter Erklärungstext, der im Detailbereich bei dauerhaft fehlgeschlagenen
|
||||
* Dokumenten angezeigt wird.
|
||||
*/
|
||||
public static final String DETAIL_TEXT_FAILED_PERMANENT =
|
||||
"Diese Datei kann nicht verarbeitet werden. Mögliche Ursachen: "
|
||||
+ "kein lesbarer Text (z. B. gescanntes Foto ohne OCR), Passwortschutz "
|
||||
+ "oder beschädigte Datei. "
|
||||
+ "Sie können den Status manuell zurücksetzen, wenn Sie die Ursache behoben haben.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Summary-Kategorielabels
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Summary-Kategorie für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||
public static final String SUMMARY_CATEGORY_SUCCESS = "erfolgreich";
|
||||
|
||||
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||
public static final String SUMMARY_CATEGORY_FAILED_RETRYABLE = "wird wiederholt";
|
||||
|
||||
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||
public static final String SUMMARY_CATEGORY_FAILED_PERMANENT = "fehlgeschlagen";
|
||||
|
||||
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||
public static final String SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED = "übersprungen";
|
||||
|
||||
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||
public static final String SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE = "endgültig übersprungen";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Record-Typ für gebündelte Darstellungsinformationen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gebündelte visuelle Darstellungsinformationen für einen Verarbeitungsstatus.
|
||||
*
|
||||
* @param icon Unicode-Zeichen als Status-Icon; nie leer
|
||||
* @param cssColor CSS-Hex-Farbe für das Icon, z. B. {@code "#2e7d32"}; nie leer
|
||||
* @param tooltipText Deutschsprachiger Tooltip-Text; nie leer
|
||||
* @param summaryCategoryLabel Kategorie-Bezeichnung für das Summary-Banner; nie leer
|
||||
*/
|
||||
public record StatusVisuals(
|
||||
String icon,
|
||||
String cssColor,
|
||||
String tooltipText,
|
||||
String summaryCategoryLabel) {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor zur Pflichtfeld-Validierung.
|
||||
*
|
||||
* @throws NullPointerException wenn ein Feld {@code null} ist
|
||||
* @throws IllegalArgumentException wenn ein String-Feld leer ist
|
||||
*/
|
||||
public StatusVisuals {
|
||||
Objects.requireNonNull(icon, "icon muss gesetzt sein");
|
||||
Objects.requireNonNull(cssColor, "cssColor muss gesetzt sein");
|
||||
Objects.requireNonNull(tooltipText, "tooltipText muss gesetzt sein");
|
||||
Objects.requireNonNull(summaryCategoryLabel, "summaryCategoryLabel muss gesetzt sein");
|
||||
if (icon.isBlank()) throw new IllegalArgumentException("icon darf nicht leer sein");
|
||||
if (cssColor.isBlank()) throw new IllegalArgumentException("cssColor darf nicht leer sein");
|
||||
if (tooltipText.isBlank()) throw new IllegalArgumentException("tooltipText darf nicht leer sein");
|
||||
if (summaryCategoryLabel.isBlank())
|
||||
throw new IllegalArgumentException("summaryCategoryLabel darf nicht leer sein");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zentrale Mapping-Methoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Liefert das Status-Icon für den angegebenen Verarbeitungsstatus.
|
||||
*
|
||||
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||
* @return das zugehörige Unicode-Zeichen; nie leer
|
||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||
*/
|
||||
public static String iconFor(DocumentCompletionStatus status) {
|
||||
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||
return switch (status) {
|
||||
case SUCCESS -> ICON_SUCCESS;
|
||||
case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE;
|
||||
case FAILED_PERMANENT -> ICON_FAILED_PERMANENT;
|
||||
case SKIPPED_ALREADY_PROCESSED -> ICON_SKIPPED_ALREADY_PROCESSED;
|
||||
case SKIPPED_FINAL_FAILURE -> ICON_SKIPPED_FINAL_FAILURE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die CSS-Hex-Farbe für das Status-Icon des angegebenen Verarbeitungsstatus.
|
||||
* <p>
|
||||
* Die Farbe ist nie das einzige Unterscheidungsmerkmal – Icon und Tooltip-Text
|
||||
* beschreiben den Status unabhängig von der Farbe eindeutig.
|
||||
*
|
||||
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||
* @return die CSS-Hex-Farbe (z. B. {@code "#2e7d32"}); nie leer
|
||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||
*/
|
||||
public static String cssColorFor(DocumentCompletionStatus status) {
|
||||
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||
return switch (status) {
|
||||
case SUCCESS -> COLOR_SUCCESS;
|
||||
case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE;
|
||||
case FAILED_PERMANENT -> COLOR_FAILED_PERMANENT;
|
||||
case SKIPPED_ALREADY_PROCESSED -> COLOR_SKIPPED_ALREADY_PROCESSED;
|
||||
case SKIPPED_FINAL_FAILURE -> COLOR_SKIPPED_FINAL_FAILURE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den deutschsprachigen Tooltip-Text für den angegebenen Verarbeitungsstatus.
|
||||
*
|
||||
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||
* @return der Tooltip-Text; nie leer
|
||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||
*/
|
||||
public static String tooltipFor(DocumentCompletionStatus status) {
|
||||
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||
return switch (status) {
|
||||
case SUCCESS -> TOOLTIP_SUCCESS;
|
||||
case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE;
|
||||
case FAILED_PERMANENT -> TOOLTIP_FAILED_PERMANENT;
|
||||
case SKIPPED_ALREADY_PROCESSED -> TOOLTIP_SKIPPED_ALREADY_PROCESSED;
|
||||
case SKIPPED_FINAL_FAILURE -> TOOLTIP_SKIPPED_FINAL_FAILURE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Summary-Kategorie-Bezeichnung für den angegebenen Verarbeitungsstatus.
|
||||
* Diese Kategorie wird im Summary-Banner nach einem Lauf angezeigt.
|
||||
*
|
||||
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||
* @return die Kategorienbezeichnung; nie leer
|
||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||
*/
|
||||
public static String summaryCategoryFor(DocumentCompletionStatus status) {
|
||||
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||
return switch (status) {
|
||||
case SUCCESS -> SUMMARY_CATEGORY_SUCCESS;
|
||||
case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE;
|
||||
case FAILED_PERMANENT -> SUMMARY_CATEGORY_FAILED_PERMANENT;
|
||||
case SKIPPED_ALREADY_PROCESSED -> SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED;
|
||||
case SKIPPED_FINAL_FAILURE -> SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert alle gebündelten visuellen Darstellungsinformationen für den angegebenen
|
||||
* Verarbeitungsstatus in einem einzigen Objekt.
|
||||
*
|
||||
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||
* @return ein befülltes {@link StatusVisuals}-Record; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||
*/
|
||||
public static StatusVisuals visualsFor(DocumentCompletionStatus status) {
|
||||
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||
return new StatusVisuals(
|
||||
iconFor(status),
|
||||
cssColorFor(status),
|
||||
tooltipFor(status),
|
||||
summaryCategoryFor(status));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mapping für ProcessingStatus (alle acht Domain-Statuswerte)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Liefert den deutschsprachigen Anzeigetext mit Icon für den angegebenen
|
||||
* Domain-Verarbeitungsstatus. Kein Enum-Rohname darf für Endnutzer sichtbar sein.
|
||||
*
|
||||
* @param status der Domain-Verarbeitungsstatus; darf nicht {@code null} sein
|
||||
* @return der Anzeigetext mit vorangestelltem Icon; nie leer
|
||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||
*/
|
||||
public static String displayTextFor(ProcessingStatus status) {
|
||||
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||
return switch (status) {
|
||||
case SUCCESS -> "✓ Erfolgreich";
|
||||
case FAILED_RETRYABLE -> "↻ Temporärer Fehler";
|
||||
case FAILED_FINAL -> "× Dauerhaft fehlgeschlagen";
|
||||
case SKIPPED_ALREADY_PROCESSED -> "≡ Bereits verarbeitet";
|
||||
case SKIPPED_FINAL_FAILURE -> "⊘ Endgültig übersprungen";
|
||||
case READY_FOR_AI -> "⟳ Wartet auf Verarbeitung";
|
||||
case PROPOSAL_READY -> "◇ Vorschlag vorhanden";
|
||||
case PROCESSING -> "▶ In Bearbeitung";
|
||||
};
|
||||
}
|
||||
|
||||
/** Nicht instanziierbar – reine Utility-Klasse. */
|
||||
private ProcessingStatusPresentation() {
|
||||
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -29,10 +29,10 @@ public record GuiConfigurationEditorState(
|
||||
* @param values current editable configuration values; must not be {@code null}
|
||||
*/
|
||||
public GuiConfigurationEditorState {
|
||||
loadedFileSnapshot = loadedFileSnapshot == null ? Optional.empty() : loadedFileSnapshot;
|
||||
loadedFileSnapshot = Objects.requireNonNullElse(loadedFileSnapshot, Optional.empty());
|
||||
baselineValues = Objects.requireNonNull(baselineValues, "baselineValues 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(text, "text must not be null");
|
||||
Objects.requireNonNull(timestamp, "timestamp must not be null");
|
||||
source = source == null ? Optional.empty() : source;
|
||||
source = Objects.requireNonNullElse(source, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
@@ -8,7 +8,9 @@ import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.Duration;
|
||||
|
||||
/**
|
||||
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
|
||||
@@ -169,6 +171,26 @@ public final class GuiModelFieldContainer extends StackPane {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf beide internen Controls
|
||||
* (ComboBox und TextField). Damit erscheint der Tooltip unabhängig davon, welches der
|
||||
* beiden Controls gerade sichtbar ist.
|
||||
* <p>
|
||||
* Darf nur auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*
|
||||
* @param tooltipText der anzuzeigende Tooltip-Text; darf nicht leer sein
|
||||
*/
|
||||
public void applyTooltip(String tooltipText) {
|
||||
Objects.requireNonNull(tooltipText, "tooltipText darf nicht null sein");
|
||||
Tooltip comboTooltip = new Tooltip(tooltipText);
|
||||
comboTooltip.setShowDelay(Duration.millis(300));
|
||||
comboBox.setTooltip(comboTooltip);
|
||||
|
||||
Tooltip textTooltip = new Tooltip(tooltipText);
|
||||
textTooltip.setShowDelay(Duration.millis(300));
|
||||
textField.setTooltip(textTooltip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX node that represents this container and can be added to the scene graph.
|
||||
*
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase}.
|
||||
* <p>
|
||||
* Löscht den Dokument-Stammsatz und alle zugehörigen Verarbeitungsversuche
|
||||
* vollständig und transaktional. Die Löschung ist destruktiv und nicht
|
||||
* rückgängig zu machen.
|
||||
* <p>
|
||||
* Die GUI muss vor dem Aufruf dieses Ports einen Bestätigungsdialog mit
|
||||
* explizitem Warnhinweis anzeigen.
|
||||
* <p>
|
||||
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||
* bis die Löschung abgeschlossen ist.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiDeleteDocumentHistoryPort {
|
||||
|
||||
/**
|
||||
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||
* <p>
|
||||
* Die Löschung erfolgt in der korrekten Reihenfolge innerhalb einer Transaktion:
|
||||
* zuerst alle {@code processing_attempt}-Einträge, dann der {@code document_record}-Stammsatz.
|
||||
* Ist kein Datensatz vorhanden, kehrt die Methode stillschweigend zurück.
|
||||
*
|
||||
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||
* darf nicht {@code null} sein
|
||||
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||
* bei technischen Datenbankfehlern
|
||||
*/
|
||||
void deleteHistory(Path configFilePath, DocumentFingerprint fingerprint);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase}.
|
||||
* <p>
|
||||
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||
* Es ist eine modul-interne Brücke, über die Bootstrap die Detaildaten
|
||||
* für einen ausgewählten Dokumenteintrag bereitstellt.
|
||||
* <p>
|
||||
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
|
||||
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann.
|
||||
* <p>
|
||||
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||
* bis das Ergebnis vollständig vorliegt.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiHistoryDetailsPort {
|
||||
|
||||
/**
|
||||
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||
*
|
||||
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||
* darf nicht {@code null} sein
|
||||
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
|
||||
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||
* bei technischen Datenbankfehlern
|
||||
*/
|
||||
Optional<HistoryDetailsResult> loadDetails(Path configFilePath, DocumentFingerprint fingerprint);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||
|
||||
/**
|
||||
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase}.
|
||||
* <p>
|
||||
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||
* Es ist eine modul-interne Brücke, über die Bootstrap die Dokumentenliste
|
||||
* für den Historien-Tab bereitstellt, ohne dass der GUI-Adapter direkt auf
|
||||
* Repository-Implementierungen zugreift.
|
||||
* <p>
|
||||
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
|
||||
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann,
|
||||
* ohne den Pfad global zu speichern.
|
||||
* <p>
|
||||
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||
* bis das Ergebnis vollständig vorliegt.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiHistoryOverviewPort {
|
||||
|
||||
/**
|
||||
* Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
|
||||
* <p>
|
||||
* Bei mehr als 500 Treffern enthält das Ergebnis genau 500 Zeilen und
|
||||
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
|
||||
*
|
||||
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||
* darf nicht {@code null} sein
|
||||
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit;
|
||||
* darf nicht {@code null} sein
|
||||
* @return Ergebnisobjekt mit Trefferliste und {@code hasMore}-Flag; nie {@code null}
|
||||
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||
* bei technischen Datenbankfehlern
|
||||
*/
|
||||
HistoryOverviewResult loadOverview(Path configFilePath, HistoryQuery query);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase}.
|
||||
* <p>
|
||||
* Führt einen feldgenauen Status-Reset durch: ausschließlich {@code overall_status},
|
||||
* {@code content_error_count}, {@code transient_error_count} und
|
||||
* {@code last_failure_instant} werden zurückgesetzt. Die Versuchshistorie bleibt
|
||||
* vollständig erhalten. Nach dem Reset gilt das Dokument beim nächsten
|
||||
* Verarbeitungslauf als verarbeitbar.
|
||||
* <p>
|
||||
* <strong>Abgrenzung zu {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort}:</strong>
|
||||
* Der bestehende Reset-Port im {@code batchrun}-Paket löscht alle Persistenzdaten
|
||||
* (Stammsatz und Versuchshistorie) vollständig. Dieser Port hier führt ausschließlich
|
||||
* einen feldgenauen Update durch und lässt die Versuchshistorie unangetastet.
|
||||
* <p>
|
||||
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||
* bis die Operation abgeschlossen ist.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiHistoryResetDocumentStatusPort {
|
||||
|
||||
/**
|
||||
* Setzt den Status des Dokuments feldgenau zurück.
|
||||
* <p>
|
||||
* Folgende Felder werden aktualisiert:
|
||||
* <ul>
|
||||
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
|
||||
* <li>{@code content_error_count} → {@code 0}</li>
|
||||
* <li>{@code transient_error_count} → {@code 0}</li>
|
||||
* <li>{@code last_failure_instant} → {@code null}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||
* darf nicht {@code null} sein
|
||||
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||
* bei technischen Datenbankfehlern
|
||||
*/
|
||||
void resetStatus(Path configFilePath, DocumentFingerprint fingerprint);
|
||||
}
|
||||
+1019
File diff suppressed because it is too large
Load Diff
+15
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* GUI-Adapter für den Historien-Tab.
|
||||
* <p>
|
||||
* Enthält die Bridge-Interfaces {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort},
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort},
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort} und
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort}
|
||||
* sowie die JavaFX-Komponente {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab}.
|
||||
* <p>
|
||||
* Die Bridge-Interfaces werden von Bootstrap implementiert und über
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext} in den GUI-Adapter injiziert.
|
||||
* Die GUI-Komponenten kennen ausschließlich diese Interfaces –
|
||||
* niemals direkt Repository- oder Use-Case-Implementierungen.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||
+10
-3
@@ -244,12 +244,18 @@ class GuiAdapterSmokeTest {
|
||||
"The 'Speichern' button must be visible");
|
||||
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
||||
"The 'Speichern unter' button must be visible");
|
||||
assertEquals(2, workspace.tabPane().getTabs().size(),
|
||||
"Configuration tab and processing-run tab must both be present");
|
||||
assertEquals(5, workspace.tabPane().getTabs().size(),
|
||||
"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(),
|
||||
"The first tab must use the configuration label");
|
||||
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
||||
"The second tab must host the processing-run view");
|
||||
assertEquals("Scheduler", workspace.tabPane().getTabs().get(2).getText(),
|
||||
"The third tab must host the scheduler control");
|
||||
assertEquals("Verlauf", workspace.tabPane().getTabs().get(3).getText(),
|
||||
"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(
|
||||
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
||||
String.join(",", workspace.sectionTitles()),
|
||||
@@ -415,7 +421,8 @@ class GuiAdapterSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
|
||||
+5
-4
@@ -202,11 +202,11 @@ class GuiEditorFieldBindingTest {
|
||||
String originalSqlite = ws.editorState().values().sqliteFile();
|
||||
|
||||
// Replace the file-picker hook: always return null (cancel).
|
||||
ws.filePickerDialog = (title, initialPath) -> null;
|
||||
ws.filePickerDialog = (title, initialPath, filters) -> null;
|
||||
|
||||
// Simulate button handler: null result means do nothing.
|
||||
String picked = ws.filePickerDialog.apply("SQLite-Datei ausw\u00e4hlen",
|
||||
ws.editorState().values().sqliteFile());
|
||||
String picked = ws.filePickerDialog.pick("SQLite-Datei ausw\u00e4hlen",
|
||||
ws.editorState().values().sqliteFile(), java.util.List.of());
|
||||
if (picked != null) {
|
||||
ws.editorState = ws.editorState()
|
||||
.withValues(ws.editorState().values().withSqliteFile(picked));
|
||||
@@ -345,7 +345,8 @@ class GuiEditorFieldBindingTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
|
||||
+6
-3
@@ -137,7 +137,8 @@ class GuiEditorIntegrationTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -287,7 +288,8 @@ class GuiEditorIntegrationTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -371,7 +373,8 @@ class GuiEditorIntegrationTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
|
||||
+10
-5
@@ -208,7 +208,8 @@ class GuiEditorRegressionSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -347,7 +348,8 @@ class GuiEditorRegressionSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -471,7 +473,8 @@ class GuiEditorRegressionSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -599,7 +602,8 @@ class GuiEditorRegressionSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -698,7 +702,8 @@ class GuiEditorRegressionSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
|
||||
+4
-2
@@ -142,7 +142,8 @@ class GuiEditorValidationSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -272,7 +273,8 @@ class GuiEditorValidationSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
|
||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Tab;
|
||||
|
||||
/**
|
||||
* Monocle-basierte Headless-Smoke-Tests für {@link GuiHistoryTab}.
|
||||
* <p>
|
||||
* Geprüfte Szenarien:
|
||||
* <ul>
|
||||
* <li>Tab wird mit Titel „Verlauf" erstellt.</li>
|
||||
* <li>Tab ist nicht schließbar.</li>
|
||||
* <li>Ohne geladene Konfiguration bleibt die Übersicht leer (null-configPath).</li>
|
||||
* <li>Mit leerem Übersichts-Port bleibt die Tabelle leer.</li>
|
||||
* </ul>
|
||||
*/
|
||||
class GuiHistoryTabSmokeTest {
|
||||
|
||||
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||
|
||||
@BeforeAll
|
||||
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||
Platform.setImplicitExit(false);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
latch.countDown();
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"JavaFX Platform muss innerhalb des Timeouts starten");
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
verifyLatch.countDown();
|
||||
});
|
||||
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownJavaFxPlatform() {
|
||||
// Gemeinsame Platform – kein Platform.exit().
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Stubs
|
||||
// =========================================================================
|
||||
|
||||
private static GuiHistoryOverviewPort emptyOverviewPort() {
|
||||
return (configFilePath, query) ->
|
||||
new HistoryOverviewResult(List.of(), false);
|
||||
}
|
||||
|
||||
private static GuiHistoryDetailsPort emptyDetailsPort() {
|
||||
return (configFilePath, fingerprint) -> Optional.empty();
|
||||
}
|
||||
|
||||
private static GuiHistoryResetDocumentStatusPort noOpResetPort() {
|
||||
return (configFilePath, fingerprint) -> { /* no-op */ };
|
||||
}
|
||||
|
||||
private static GuiDeleteDocumentHistoryPort noOpDeletePort() {
|
||||
return (configFilePath, fingerprint) -> { /* no-op */ };
|
||||
}
|
||||
|
||||
private static GuiHistoryTab buildTab(Path configPath) {
|
||||
return new GuiHistoryTab(
|
||||
emptyOverviewPort(),
|
||||
emptyDetailsPort(),
|
||||
noOpResetPort(),
|
||||
noOpDeletePort(),
|
||||
() -> false,
|
||||
() -> configPath);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void tab_shouldHaveTitleVerlauf() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<Tab> tabRef = new AtomicReference<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
GuiHistoryTab historyTab = buildTab(null);
|
||||
tabRef.set(historyTab.tab());
|
||||
} 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());
|
||||
}
|
||||
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
|
||||
assertEquals("Verlauf", tabRef.get().getText(), "Tab-Titel muss 'Verlauf' sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tab_shouldNotBeClosable() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicBoolean closableRef = new AtomicBoolean(true);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
GuiHistoryTab historyTab = buildTab(null);
|
||||
closableRef.set(historyTab.tab().isClosable());
|
||||
} 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(closableRef.get(), "Tab darf nicht schließbar sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void construction_withNullConfigPath_doesNotThrow() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
// Konstruktion mit null-configPath-Supplier muss möglich sein
|
||||
GuiHistoryTab historyTab = buildTab(null);
|
||||
assertNotNull(historyTab.tab());
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void construction_withConfigPath_doesNotThrow() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
Path dummyPath = Paths.get("config/application.properties");
|
||||
GuiHistoryTab historyTab = buildTab(dummyPath);
|
||||
assertNotNull(historyTab.tab());
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -336,7 +336,8 @@ class GuiMessageAreaSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -478,7 +479,8 @@ class GuiMessageAreaSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -565,7 +567,8 @@ class GuiMessageAreaSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -888,7 +891,8 @@ class GuiMessageAreaSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
|
||||
+2
-1
@@ -529,7 +529,8 @@ class GuiModelCatalogSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Tab;
|
||||
|
||||
/**
|
||||
* Monocle-basierte Headless-Smoke-Tests für {@link GuiPromptEditorTab}.
|
||||
* <p>
|
||||
* Geprüfte Szenarien:
|
||||
* <ul>
|
||||
* <li>Tab wird korrekt mit Titel „Prompt" erstellt.</li>
|
||||
* <li>Dirty-State ist nach Konstruktion {@code false}.</li>
|
||||
* <li>Nach synchronem Laden mit Erfolg: Dirty-State bleibt {@code false},
|
||||
* Tab-Titel enthält keinen Asterisk.</li>
|
||||
* <li>Nach synchronem Laden mit FILE_NOT_FOUND: Dirty-State bleibt {@code false}.</li>
|
||||
* <li>Nach synchronem Speichern mit Erfolg: Dirty-State zurückgesetzt.</li>
|
||||
* <li>Nach {@code resetToDefault}: Textfeld enthält Default-Inhalt (nicht leer),
|
||||
* Dirty-State ist {@code true} (Abweichung von geladener Baseline).</li>
|
||||
* </ul>
|
||||
*/
|
||||
class GuiPromptEditorTabSmokeTest {
|
||||
|
||||
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||
|
||||
@BeforeAll
|
||||
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||
Platform.setImplicitExit(false);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
latch.countDown();
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"JavaFX Platform muss innerhalb des Timeouts starten");
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
verifyLatch.countDown();
|
||||
});
|
||||
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownJavaFxPlatform() {
|
||||
// Gemeinsame Platform – kein Platform.exit().
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hilfsklassen
|
||||
// =========================================================================
|
||||
|
||||
/** Synchroner Stub-Port: gibt vorbereitete Ergebnisse sofort zurück. */
|
||||
private static class SyncPromptEditorPort implements GuiPromptEditorPort {
|
||||
PromptLoadingResult loadResult = new PromptLoadingSuccess(
|
||||
new PromptIdentifier("test-prompt.txt"), "Stub-Prompt-Inhalt");
|
||||
PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/test-prompt.txt");
|
||||
|
||||
@Override
|
||||
public PromptLoadingResult loadCurrentPrompt() {
|
||||
return loadResult;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PromptSaveResult save(String content) {
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CorrectionOutcome createDefaultPromptIfMissing(
|
||||
CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||
return new CorrectionOutcome.Applied(suggestion, "Stub-Prompt-Datei angelegt.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen {@link GuiPromptEditorTab} mit synchronen Stubs:
|
||||
* threadFactory führt den Runnable inline aus (vor worker.start()),
|
||||
* fxDispatcher gibt den UI-Update-Runnable direkt weiter (kein Platform.runLater).
|
||||
* Damit sind alle Operationen aus Testsicht vollständig synchron.
|
||||
*/
|
||||
private static GuiPromptEditorTab buildSyncTab(SyncPromptEditorPort port) {
|
||||
GuiPromptEditorTab tab = new GuiPromptEditorTab(port, "/stub/test-prompt.txt", 60);
|
||||
// Runnable wird inline ausgeführt; der zurückgegebene Thread startet leer (kein-op).
|
||||
tab.threadFactory = runnable -> {
|
||||
runnable.run(); // Synchron ausführen, inkl. fxDispatcher-Aufruf
|
||||
return new Thread(); // Dummy-Thread; worker.start() beendet sofort
|
||||
};
|
||||
// UI-Updates synchron im selben Thread
|
||||
tab.fxDispatcher = Runnable::run;
|
||||
return tab;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void tab_shouldBeCreatedWithTitlePrompt() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<Tab> tabRef = new AtomicReference<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
tabRef.set(editorTab.tab());
|
||||
} 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());
|
||||
}
|
||||
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
|
||||
assertEquals("Prompt", tabRef.get().getText(), "Tab-Titel muss 'Prompt' sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyState_shouldBeFalse_afterConstruction() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
dirtyRef.set(editorTab.hasDirtyContent());
|
||||
} 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 Konstruktion false sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyState_shouldBeFalse_afterSuccessfulLoad() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
// Laden synchron auslösen (fxDispatcher = Runnable::run)
|
||||
editorTab.loadPromptAsync();
|
||||
dirtyRef.set(editorTab.hasDirtyContent());
|
||||
} 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 erfolgreichem Laden false sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tabTitle_shouldNotContainAsterisk_afterSuccessfulLoad() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
editorTab.loadPromptAsync();
|
||||
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(titleRef.get().contains("*"),
|
||||
"Tab-Titel darf nach erfolgreichem Laden keinen Asterisk enthalten");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyState_shouldBeFalse_whenLoadReturnsFileNotFound() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
port.loadResult = new PromptLoadingFailure("FILE_NOT_FOUND", "Datei nicht gefunden");
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
editorTab.loadPromptAsync();
|
||||
dirtyRef.set(editorTab.hasDirtyContent());
|
||||
} 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 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
|
||||
void tabTitle_shouldContainAsterisk_afterEditWithLoadedBaseline() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
editorTab.loadPromptAsync();
|
||||
// Direkte TextArea-Manipulation simuliert Benutzer-Eingabe
|
||||
// Über Reflection auf das private textArea-Feld zugreifen ist unerwünscht.
|
||||
// Stattdessen: resetToDefault() setzt einen anderen Inhalt als den geladenen,
|
||||
// was den Dirty-State auslöst.
|
||||
editorTab.resetToDefault();
|
||||
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());
|
||||
}
|
||||
// Nach resetToDefault() wird der Default-Inhalt gesetzt.
|
||||
// Falls dieser vom geladenen Inhalt abweicht, entsteht ein Dirty-State.
|
||||
// Da Stub-Inhalt != Default-Template, muss Asterisk vorhanden sein.
|
||||
assertTrue(titleRef.get().contains("*"),
|
||||
"Tab-Titel muss nach Bearbeitung (resetToDefault) einen Asterisk enthalten; Titel war: "
|
||||
+ 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());
|
||||
}
|
||||
}
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import javafx.application.Platform;
|
||||
|
||||
/**
|
||||
* Tests für die Statuszeilen-Komponente {@link GuiStatusBar}.
|
||||
* <p>
|
||||
* Überprüft die Versionsanzeige, den Provider-Text und den Konfigurationspfad-Text
|
||||
* in den verschiedenen Zuständen (ohne und mit geladener Konfiguration).
|
||||
* <p>
|
||||
* Die Tests laufen unter Monocle (Headless-JavaFX), da {@link GuiStatusBar} JavaFX-Controls erzeugt.
|
||||
*/
|
||||
class GuiStatusBarTest {
|
||||
|
||||
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* Initialisiert die JavaFX-Plattform einmalig für alle Tests dieser Klasse.
|
||||
*
|
||||
* @throws InterruptedException falls der Thread beim Warten unterbrochen wird
|
||||
*/
|
||||
@BeforeAll
|
||||
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||
Platform.setImplicitExit(false);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
startLatch.countDown();
|
||||
});
|
||||
} catch (IllegalStateException alreadyInitialized) {
|
||||
// JavaFX wurde bereits durch einen anderen Test gestartet
|
||||
PLATFORM_STARTED.set(true);
|
||||
startLatch.countDown();
|
||||
}
|
||||
assertTrue(
|
||||
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"JavaFX-Plattform muss innerhalb des Timeouts starten");
|
||||
}
|
||||
|
||||
/** Plattform bleibt für nachfolgende Tests am Leben. */
|
||||
@AfterAll
|
||||
static void tearDownJavaFxPlatform() {
|
||||
// Absichtlich kein Platform.exit() – damit andere Smoke-Tests weiterhin die Plattform nutzen können.
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Versionsanzeige
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Prüft, dass die Versionsanzeige das korrekte Präfix und die übergebene Version enthält.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void versionLabel_zeigtVersionMitPraefix() throws Exception {
|
||||
AtomicReference<String> versionText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar("3.0.42");
|
||||
versionText.set(bar.versionText());
|
||||
});
|
||||
assertEquals("V3.0.42", versionText.get(),
|
||||
"Die Versionsanzeige muss das Präfix 'V' gefolgt von der Versionsnummer enthalten");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass ein {@code null}-Wert für die Version als {@code "dev"} angezeigt wird.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void versionLabel_mitNullFaellzurueckAufDev() throws Exception {
|
||||
AtomicReference<String> versionText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar(null);
|
||||
versionText.set(bar.versionText());
|
||||
});
|
||||
assertEquals("Vdev", versionText.get(),
|
||||
"Ein null-Wert muss als Fallback 'dev' angezeigt werden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass ein leerer String für die Version als {@code "dev"} angezeigt wird.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void versionLabel_mitLeeremStringFaellzurueckAufDev() throws Exception {
|
||||
AtomicReference<String> versionText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar(" ");
|
||||
versionText.set(bar.versionText());
|
||||
});
|
||||
assertEquals("Vdev", versionText.get(),
|
||||
"Ein leerer String muss als Fallback 'dev' angezeigt werden");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Standardzustand ohne geladene Konfiguration
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Prüft, dass Mitte und Rechts den Text „Kein Profil geladen" zeigen, wenn keine
|
||||
* Konfiguration geladen ist.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void ohneKonfiguration_zeigtKeinProfilGeladen() throws Exception {
|
||||
AtomicReference<String> providerText = new AtomicReference<>();
|
||||
AtomicReference<String> configPathText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||
providerText.set(bar.providerText());
|
||||
configPathText.set(bar.configPathText());
|
||||
});
|
||||
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
|
||||
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Provider-Text erscheinen");
|
||||
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
|
||||
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass {@link GuiStatusBar#clearConfiguration()} Mitte und Rechts zurücksetzt.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void clearConfiguration_setztMitteUndRechtsZurueck() throws Exception {
|
||||
AtomicReference<String> providerText = new AtomicReference<>();
|
||||
AtomicReference<String> configPathText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||
// Zustand mit Konfiguration setzen, dann löschen
|
||||
GuiConfigurationEditorState state = buildStateWithConfiguration(
|
||||
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
|
||||
bar.applyEditorState(state);
|
||||
bar.clearConfiguration();
|
||||
providerText.set(bar.providerText());
|
||||
configPathText.set(bar.configPathText());
|
||||
});
|
||||
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
|
||||
"Nach clearConfiguration() muss 'Kein Profil geladen' als Provider-Text erscheinen");
|
||||
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
|
||||
"Nach clearConfiguration() muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Zustand nach Laden einer Konfiguration
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Provider-Text
|
||||
* mit Modell angezeigt wird.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void applyEditorState_mitClaudeUndModell_zeigtKorrektesFormat() throws Exception {
|
||||
AtomicReference<String> providerText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||
GuiConfigurationEditorState state = buildStateWithConfiguration(
|
||||
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
|
||||
bar.applyEditorState(state);
|
||||
providerText.set(bar.providerText());
|
||||
});
|
||||
assertEquals("Provider: Claude · claude-opus-4-7", providerText.get(),
|
||||
"Der Provider-Text muss das Format 'Provider: <Name> · <Modell>' haben");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Konfigurationspfad
|
||||
* angezeigt wird.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void applyEditorState_mitKonfigurationspfad_zeigtKonfiguationspfad() throws Exception {
|
||||
AtomicReference<String> configPathText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||
GuiConfigurationEditorState state = buildStateWithConfiguration(
|
||||
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
|
||||
bar.applyEditorState(state);
|
||||
configPathText.set(bar.configPathText());
|
||||
});
|
||||
assertTrue(configPathText.get().contains("application.properties"),
|
||||
"Der Konfigurationspfad muss den Dateinamen enthalten");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass ein OpenAI-kompatibler Provider korrekt angezeigt wird.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void applyEditorState_mitOpenAiUndModell_zeigtKorrektesFormat() throws Exception {
|
||||
AtomicReference<String> providerText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||
GuiConfigurationEditorState state = buildStateWithConfiguration(
|
||||
"config/application.properties", AiProviderFamily.OPENAI_COMPATIBLE, "gpt-4o");
|
||||
bar.applyEditorState(state);
|
||||
providerText.set(bar.providerText());
|
||||
});
|
||||
assertEquals("Provider: OpenAI-kompatibel · gpt-4o", providerText.get(),
|
||||
"Der Provider-Text muss für OpenAI-kompatibel den deutschen Anzeigenamen verwenden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass beim Übergeben eines {@code null}-Zustands kein Absturz erfolgt und der
|
||||
* Text „Kein Profil geladen" erscheint.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void applyEditorState_mitNull_keinAbsturz() throws Exception {
|
||||
AtomicReference<String> providerText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||
bar.applyEditorState(null);
|
||||
providerText.set(bar.providerText());
|
||||
});
|
||||
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
|
||||
"Ein null-Zustand darf keinen Absturz verursachen");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass ohne geladenen Dateisnapshot „Kein Profil geladen" angezeigt wird,
|
||||
* auch wenn Konfigurationswerte vorhanden sind.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void applyEditorState_ohneSnapshot_zeigtKeinProfilGeladen() throws Exception {
|
||||
AtomicReference<String> providerText = new AtomicReference<>();
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||
// Standard-Template hat keinen Snapshot
|
||||
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
|
||||
bar.applyEditorState(state);
|
||||
providerText.set(bar.providerText());
|
||||
});
|
||||
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
|
||||
"Ohne geladenen Dateisnapshot muss 'Kein Profil geladen' erscheinen");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass der Wurzelknoten der Statuszeile nicht null ist.
|
||||
*
|
||||
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||
*/
|
||||
@Test
|
||||
void root_istNichtNull() throws Exception {
|
||||
AtomicBoolean rootNotNull = new AtomicBoolean(false);
|
||||
runOnFxThread(() -> {
|
||||
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||
rootNotNull.set(bar.root() != null);
|
||||
});
|
||||
assertTrue(rootNotNull.get(), "Der Wurzelknoten der Statuszeile darf nicht null sein");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hilfsmethoden
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Führt eine Aktion synchron auf dem JavaFX Application Thread aus und wartet auf Abschluss.
|
||||
*
|
||||
* @param action die auszuführende Aktion
|
||||
* @throws Exception falls die Aktion einen Fehler wirft oder das Timeout überschritten wird
|
||||
*/
|
||||
private static void runOnFxThread(Runnable action) throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable t) {
|
||||
error.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"FX-Thread-Task muss innerhalb des Timeouts abgeschlossen werden");
|
||||
if (error.get() != null) {
|
||||
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", error.get());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen Editor-Zustand mit geladenem Dateisnapshot für den angegebenen
|
||||
* Konfigurationspfad, Provider und Modell.
|
||||
*
|
||||
* @param configPath relativer Konfigurationsdateipfad
|
||||
* @param family aktive Provider-Familie
|
||||
* @param model Modellbezeichner
|
||||
* @return ein Editor-Zustand mit Snapshot
|
||||
*/
|
||||
private static GuiConfigurationEditorState buildStateWithConfiguration(
|
||||
String configPath, AiProviderFamily family, String model) {
|
||||
GuiConfigurationEditorState template = GuiConfigurationTemplateFactory.createStandardTemplate();
|
||||
// Provider und Modell setzen
|
||||
GuiProviderConfigurationState providerState = new GuiProviderConfigurationState(
|
||||
"https://api.example.com", model, "30",
|
||||
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState.unresolved());
|
||||
GuiConfigurationValues values = template.values()
|
||||
.withActiveProviderFamily(family.getIdentifier())
|
||||
.withProviderConfiguration(family, providerState);
|
||||
// Snapshot anlegen
|
||||
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
|
||||
Path.of(configPath), new Properties());
|
||||
return new GuiConfigurationEditorState(
|
||||
Optional.of(snapshot), values, values, Optional.empty());
|
||||
}
|
||||
}
|
||||
+20
-15
@@ -39,7 +39,7 @@ import javafx.scene.control.Button;
|
||||
* {@code technical-tests-button}.</li>
|
||||
* <li>Triggering the coordinator synchronously populates {@code pendingMessages}
|
||||
* 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>
|
||||
* </ul>
|
||||
* <p>
|
||||
@@ -138,12 +138,12 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@Test
|
||||
void trigger_producesElevenCheckpointEntriesPlusSummary() throws Exception {
|
||||
void trigger_producesTwelveCheckpointEntriesPlusSummary() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||
@@ -155,25 +155,26 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
|
||||
// 11 checkpoint entries + 1 summary entry = 12
|
||||
assertEquals(12, taggedCount,
|
||||
"Expected 11 checkpoint entries + 1 summary entry = 12 tagged messages");
|
||||
// 12 checkpoint entries + 1 summary entry = 13
|
||||
assertEquals(13, taggedCount,
|
||||
"Expected 12 checkpoint entries + 1 summary entry = 13 tagged messages");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: accumulation semantics – second trigger appends fresh entries
|
||||
// Scenario: replace semantics – second trigger replaces the previous batch
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: triggering the coordinator twice accumulates both runs; the
|
||||
* second trigger appends a fresh batch of SOURCE_TAG entries without
|
||||
* removing the first batch.
|
||||
* Smoke test: triggering the coordinator twice replaces the previous batch;
|
||||
* the second trigger clears the shared message list before applying its own
|
||||
* SOURCE_TAG entries, so the count after the second run equals the count
|
||||
* after the first run.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void trigger_twice_accumulatesTestEntries() throws Exception {
|
||||
void trigger_twice_replacesTestEntries() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||
@@ -190,8 +191,8 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
|
||||
assertEquals(countAfterFirst * 2, countAfterSecond,
|
||||
"Second trigger must append a fresh batch, doubling the SOURCE_TAG entries");
|
||||
assertEquals(countAfterFirst, countAfterSecond,
|
||||
"Second trigger must clear and replace the previous SOURCE_TAG batch");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,12 +256,14 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||
new EditorConfigurationValidator(),
|
||||
noOpPathCheckPort(),
|
||||
noOpProviderService());
|
||||
noOpProviderService(),
|
||||
() -> java.util.Optional.empty());
|
||||
|
||||
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
||||
orchestrator,
|
||||
currentInput::get, // always reads the current reference
|
||||
() -> "",
|
||||
() -> "",
|
||||
messages,
|
||||
report -> { });
|
||||
|
||||
@@ -364,7 +367,8 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||
new EditorConfigurationValidator(),
|
||||
noOpPathCheckPort(),
|
||||
noOpProviderService());
|
||||
noOpProviderService(),
|
||||
() -> java.util.Optional.empty());
|
||||
|
||||
EditorValidationInput blankInput = new EditorValidationInput(
|
||||
"claude",
|
||||
@@ -379,6 +383,7 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
orchestrator,
|
||||
() -> blankInput,
|
||||
() -> "",
|
||||
() -> "",
|
||||
messages,
|
||||
postResultCallback);
|
||||
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit-Tests für {@link GuiTooltipTexts}.
|
||||
* <p>
|
||||
* Prüft, dass alle öffentlichen Tooltip-Konstanten vorhanden sind, nicht leer sind
|
||||
* und den exakten Texten gemäß Spezifikation entsprechen.
|
||||
*/
|
||||
class GuiTooltipTextsTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Vollständigkeit und Nicht-Leerheit aller Konstanten
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void alleKonstantenSindNichtNullUndNichtLeer() {
|
||||
List<String> fehler = new ArrayList<>();
|
||||
for (Field field : GuiTooltipTexts.class.getDeclaredFields()) {
|
||||
if (!Modifier.isPublic(field.getModifiers())
|
||||
|| !Modifier.isStatic(field.getModifiers())
|
||||
|| !Modifier.isFinal(field.getModifiers())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Object value = field.get(null);
|
||||
if (value == null) {
|
||||
fehler.add(field.getName() + " ist null");
|
||||
} else if (value instanceof String s && s.isBlank()) {
|
||||
fehler.add(field.getName() + " ist leer");
|
||||
}
|
||||
} catch (IllegalAccessException e) {
|
||||
fehler.add(field.getName() + " nicht zugreifbar: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
if (!fehler.isEmpty()) {
|
||||
org.junit.jupiter.api.Assertions.fail(
|
||||
"Fehlerhafte Tooltip-Konstanten: " + String.join(", ", fehler));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Toolbar-Tooltips – exakter Text gemäß Spezifikation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void toolbar_neu_entsprichtSpezifikation() {
|
||||
assertNotNull(GuiTooltipTexts.TOOLBAR_NEU);
|
||||
assertFalse(GuiTooltipTexts.TOOLBAR_NEU.isBlank());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Neue Konfiguration erstellen.",
|
||||
GuiTooltipTexts.TOOLBAR_NEU);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolbar_oeffnen_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Bestehende Konfigurationsdatei (.properties) öffnen.",
|
||||
GuiTooltipTexts.TOOLBAR_OEFFNEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolbar_speichern_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Aktuelle Konfiguration speichern.",
|
||||
GuiTooltipTexts.TOOLBAR_SPEICHERN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolbar_speichernUnter_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Konfiguration unter neuem Dateipfad speichern.",
|
||||
GuiTooltipTexts.TOOLBAR_SPEICHERN_UNTER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolbar_validieren_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.",
|
||||
GuiTooltipTexts.TOOLBAR_VALIDIEREN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolbar_technischeTests_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.",
|
||||
GuiTooltipTexts.TOOLBAR_TECHNISCHE_TESTS);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pfade-Tooltips – exakter Text gemäß Spezifikation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void pfade_quellordner_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.",
|
||||
GuiTooltipTexts.PFADE_QUELLORDNER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pfade_zielordner_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Ordner für die umbenannten Kopien.",
|
||||
GuiTooltipTexts.PFADE_ZIELORDNER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pfade_sqlite_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.",
|
||||
GuiTooltipTexts.PFADE_SQLITE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pfade_prompt_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Externe Textdatei mit den KI-Anweisungen.",
|
||||
GuiTooltipTexts.PFADE_PROMPT);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Provider-Tooltips – exakter Text gemäß Spezifikation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void provider_combobox_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Der KI-Dienst, der die Dateinamen generiert.",
|
||||
GuiTooltipTexts.PROVIDER_COMBOBOX);
|
||||
}
|
||||
|
||||
@Test
|
||||
void provider_modell_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Das konkrete Sprachmodell des gewählten Providers.",
|
||||
GuiTooltipTexts.PROVIDER_MODELL);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verarbeitungslimits-Tooltips – exakter Text gemäß Spezifikation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void limits_maxTextCharacters_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.",
|
||||
GuiTooltipTexts.LIMITS_MAX_TEXT_CHARACTERS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void limits_maxPages_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Maximale Seitenzahl, die aus einem PDF gelesen wird.",
|
||||
GuiTooltipTexts.LIMITS_MAX_PAGES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void limits_maxTitleLength_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10–120.",
|
||||
GuiTooltipTexts.LIMITS_MAX_TITLE_LENGTH);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verarbeitungslauf-Tab-Tooltips – exakter Text gemäß Spezifikation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void dateiname_uebernehmen_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.",
|
||||
GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dateiname_zuruecksetzen_entsprichtSpezifikation() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.",
|
||||
GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Nicht instanziierbar
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void konstruktorWirftException() throws Exception {
|
||||
Constructor<GuiTooltipTexts> ctor = GuiTooltipTexts.class.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
assertThrows(java.lang.reflect.InvocationTargetException.class, ctor::newInstance,
|
||||
"Der private Konstruktor muss UnsupportedOperationException werfen");
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -806,7 +806,8 @@ class GuiUnsavedChangesGuardSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
@@ -851,7 +852,8 @@ class GuiUnsavedChangesGuardSmokeTest {
|
||||
},
|
||||
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"),
|
||||
(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.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"); }
|
||||
|
||||
+4
-2
@@ -323,7 +323,8 @@ class GuiValidateActionSmokeTest {
|
||||
},
|
||||
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"),
|
||||
noOpApiKeyResolutionPort())),
|
||||
noOpApiKeyResolutionPort()),
|
||||
() -> java.util.Optional.empty()),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||
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"); }
|
||||
@@ -390,7 +391,8 @@ class GuiValidateActionSmokeTest {
|
||||
},
|
||||
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"),
|
||||
noOpApiKeyResolutionPort())),
|
||||
noOpApiKeyResolutionPort()),
|
||||
() -> java.util.Optional.empty()),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||
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"); }
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Unit-Tests für {@link BatchRunSummaryBanner}.
|
||||
* <p>
|
||||
* Geprüft werden die Aggregationslogik und die Textgenerierung unabhängig von JavaFX.
|
||||
* Die GUI-Integrationsmethoden ({@code clear()}, {@code update()}, {@code getNode()})
|
||||
* erfordern eine JavaFX-Runtime und werden durch Smoke-Tests abgedeckt.
|
||||
*/
|
||||
class BatchRunSummaryBannerTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden für Testdaten
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
|
||||
return new GuiBatchRunResultRow(
|
||||
"test.pdf",
|
||||
new DocumentFingerprint("a".repeat(64)),
|
||||
status,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Duration.ZERO,
|
||||
false,
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
private static GuiBatchRunResultRow resetPendingRow() {
|
||||
GuiBatchRunResultRow base = row(DocumentCompletionStatus.SUCCESS);
|
||||
return GuiBatchRunResultRow.resetMarker(base);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// aggregateCounts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void aggregateCounts_leereListe_alleZaehlerNull() {
|
||||
Map<DocumentCompletionStatus, Integer> counts =
|
||||
BatchRunSummaryBanner.aggregateCounts(Collections.emptyList());
|
||||
|
||||
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SUCCESS, 0));
|
||||
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_RETRYABLE, 0));
|
||||
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_PERMANENT, 0));
|
||||
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0));
|
||||
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateCounts_nurErfolgreiche_zaehltNurSuccess() {
|
||||
List<GuiBatchRunResultRow> rows = List.of(
|
||||
row(DocumentCompletionStatus.SUCCESS),
|
||||
row(DocumentCompletionStatus.SUCCESS),
|
||||
row(DocumentCompletionStatus.SUCCESS));
|
||||
|
||||
Map<DocumentCompletionStatus, Integer> counts =
|
||||
BatchRunSummaryBanner.aggregateCounts(rows);
|
||||
|
||||
assertEquals(3, counts.get(DocumentCompletionStatus.SUCCESS));
|
||||
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
|
||||
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||
assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
|
||||
assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateCounts_gemischterLauf_alleKategorienKorrekt() {
|
||||
List<GuiBatchRunResultRow> rows = List.of(
|
||||
row(DocumentCompletionStatus.SUCCESS),
|
||||
row(DocumentCompletionStatus.SUCCESS),
|
||||
row(DocumentCompletionStatus.FAILED_RETRYABLE),
|
||||
row(DocumentCompletionStatus.FAILED_PERMANENT),
|
||||
row(DocumentCompletionStatus.FAILED_PERMANENT),
|
||||
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
|
||||
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
|
||||
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
|
||||
row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
|
||||
|
||||
Map<DocumentCompletionStatus, Integer> counts =
|
||||
BatchRunSummaryBanner.aggregateCounts(rows);
|
||||
|
||||
assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS));
|
||||
assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
|
||||
assertEquals(2, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||
assertEquals(3, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
|
||||
assertEquals(1, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateCounts_resetPendingZeilenWerdenNichtGezaehlt() {
|
||||
// Reset-Pending-Zeilen haben noch keinen abgeschlossenen Status und
|
||||
// dürfen nicht ins Summary einfließen
|
||||
List<GuiBatchRunResultRow> rows = List.of(
|
||||
row(DocumentCompletionStatus.SUCCESS),
|
||||
resetPendingRow(),
|
||||
resetPendingRow());
|
||||
|
||||
Map<DocumentCompletionStatus, Integer> counts =
|
||||
BatchRunSummaryBanner.aggregateCounts(rows);
|
||||
|
||||
assertEquals(1, counts.get(DocumentCompletionStatus.SUCCESS));
|
||||
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
|
||||
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// buildBannerText
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void buildBannerText_alleZaehlerNull_leerString() {
|
||||
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||
DocumentCompletionStatus.SUCCESS, 0,
|
||||
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
|
||||
DocumentCompletionStatus.FAILED_PERMANENT, 0,
|
||||
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
|
||||
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
|
||||
|
||||
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||
|
||||
assertTrue(text.isEmpty(), "Leere Zähler ergeben leeren Text: '" + text + "'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildBannerText_nurErfolgreiche_nurSuccessSegment() {
|
||||
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||
DocumentCompletionStatus.SUCCESS, 17,
|
||||
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
|
||||
DocumentCompletionStatus.FAILED_PERMANENT, 0,
|
||||
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
|
||||
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
|
||||
|
||||
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||
|
||||
assertTrue(text.contains("17"), "Anzahl 17 muss im Text erscheinen: " + text);
|
||||
assertTrue(text.contains("erfolgreich"), "Kategorie 'erfolgreich' muss erscheinen: " + text);
|
||||
assertTrue(text.contains("✓"), "Icon ✓ muss erscheinen: " + text);
|
||||
assertFalse(text.contains("↻"), "Kein ↻ wenn FAILED_RETRYABLE = 0: " + text);
|
||||
assertFalse(text.contains("×"), "Kein × wenn FAILED_PERMANENT = 0: " + text);
|
||||
assertFalse(text.contains("≡"), "Kein ≡ wenn SKIPPED_ALREADY_PROCESSED = 0: " + text);
|
||||
assertFalse(text.contains("⊘"), "Kein ⊘ wenn SKIPPED_FINAL_FAILURE = 0: " + text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildBannerText_vollerLauf_alleSegmenteEnthalten() {
|
||||
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||
DocumentCompletionStatus.SUCCESS, 14,
|
||||
DocumentCompletionStatus.FAILED_RETRYABLE, 1,
|
||||
DocumentCompletionStatus.FAILED_PERMANENT, 2,
|
||||
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 3,
|
||||
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 1);
|
||||
|
||||
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||
|
||||
// Jedes Segment enthält Icon + Anzahl + Kategorie
|
||||
assertTrue(text.contains("✓ 14 erfolgreich"), "SUCCESS-Segment: " + text);
|
||||
assertTrue(text.contains("↻ 1 wird wiederholt"), "FAILED_RETRYABLE-Segment: " + text);
|
||||
assertTrue(text.contains("× 2 fehlgeschlagen"), "FAILED_PERMANENT-Segment: " + text);
|
||||
assertTrue(text.contains("≡ 3 übersprungen"), "SKIPPED_ALREADY_PROCESSED-Segment: " + text);
|
||||
assertTrue(text.contains("⊘ 1 endgültig übersprungen"), "SKIPPED_FINAL_FAILURE-Segment: " + text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildBannerText_nurSkippedFinalFailure_erscheintImBanner() {
|
||||
// Sicherstellung: ⊘ erscheint auch wenn > 0, obwohl es die seltenste Kategorie ist
|
||||
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||
DocumentCompletionStatus.SUCCESS, 0,
|
||||
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
|
||||
DocumentCompletionStatus.FAILED_PERMANENT, 0,
|
||||
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
|
||||
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 2);
|
||||
|
||||
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||
|
||||
assertTrue(text.contains("⊘"), "Icon ⊘ muss erscheinen: " + text);
|
||||
assertTrue(text.contains("2"), "Anzahl 2 muss erscheinen: " + text);
|
||||
assertTrue(text.contains("endgültig übersprungen"), "Kategorie muss erscheinen: " + text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildBannerText_nurKategorienMitAnzahlGroesserNull_erscheinen() {
|
||||
// Nur SUCCESS=5 ist gesetzt; alle anderen 0 → kein anderes Segment
|
||||
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||
DocumentCompletionStatus.SUCCESS, 5,
|
||||
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
|
||||
DocumentCompletionStatus.FAILED_PERMANENT, 0,
|
||||
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
|
||||
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
|
||||
|
||||
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||
|
||||
// Kein Trennzeichen (·) darf erscheinen, wenn nur ein Segment vorhanden ist
|
||||
assertFalse(text.contains("·"), "Kein Trenner bei einzelnem Segment: " + text);
|
||||
assertTrue(text.contains("✓ 5 erfolgreich"), "Nur SUCCESS-Segment: " + text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateCounts_kombinationMitResetPending_nurEchtAbgeschlosseneGezaehlt() {
|
||||
// 2 SUCCESS + 1 FAILED_PERMANENT + 1 resetPending(SUCCESS) → nur 2+1 gezählt
|
||||
List<GuiBatchRunResultRow> rows = List.of(
|
||||
row(DocumentCompletionStatus.SUCCESS),
|
||||
row(DocumentCompletionStatus.SUCCESS),
|
||||
row(DocumentCompletionStatus.FAILED_PERMANENT),
|
||||
resetPendingRow());
|
||||
|
||||
Map<DocumentCompletionStatus, Integer> counts =
|
||||
BatchRunSummaryBanner.aggregateCounts(rows);
|
||||
|
||||
assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS));
|
||||
assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||
// Summe aller gezählten Einträge = 3, nicht 4
|
||||
int total = counts.values().stream().mapToInt(Integer::intValue).sum();
|
||||
assertEquals(3, total, "Reset-Pending darf nicht mitgezählt werden");
|
||||
}
|
||||
}
|
||||
+36
-12
@@ -119,9 +119,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
|
||||
void startReset_invokesResetPortAndDispatchesResult() {
|
||||
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
|
||||
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
|
||||
captured.set(result);
|
||||
}
|
||||
@@ -170,9 +176,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
|
||||
void startReset_portThrowsException_mapsToAllFailures() {
|
||||
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
|
||||
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
|
||||
captured.set(result);
|
||||
}
|
||||
@@ -198,9 +210,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
|
||||
void listenerDefaultOnResetCompleted_doesNotThrow() {
|
||||
// Verify the default implementation is safe to call.
|
||||
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
// intentionally empty
|
||||
}
|
||||
@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()));
|
||||
}
|
||||
@@ -223,9 +241,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
|
||||
|
||||
private static GuiBatchRunCoordinator.Listener noOpListener() {
|
||||
return new GuiBatchRunCoordinator.Listener() {
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||
// intentionally empty
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+25
-11
@@ -247,8 +247,12 @@ class GuiBatchRunCoordinatorTest {
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
launcher, syncThreadFactory(), syncDispatcher(),
|
||||
new GuiBatchRunCoordinator.Listener() {
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||
captured.set(outcome);
|
||||
}
|
||||
@@ -270,8 +274,12 @@ class GuiBatchRunCoordinatorTest {
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
launcher, syncThreadFactory(), syncDispatcher(),
|
||||
new GuiBatchRunCoordinator.Listener() {
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||
captured.set(outcome);
|
||||
}
|
||||
@@ -287,10 +295,10 @@ class GuiBatchRunCoordinatorTest {
|
||||
|
||||
@Test
|
||||
void resultRowIcons_matchSpecification() {
|
||||
assertEquals("\u2714", row(DocumentCompletionStatus.SUCCESS).statusIcon());
|
||||
assertEquals("\u26A0", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
|
||||
assertEquals("\u2718", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
|
||||
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
|
||||
assertEquals("✓", row(DocumentCompletionStatus.SUCCESS).statusIcon());
|
||||
assertEquals("↻", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
|
||||
assertEquals("×", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
|
||||
assertEquals("≡", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -322,9 +330,15 @@ class GuiBatchRunCoordinatorTest {
|
||||
|
||||
private static GuiBatchRunCoordinator.Listener noOpListener() {
|
||||
return new GuiBatchRunCoordinator.Listener() {
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||
// intentionally empty
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+60
-9
@@ -21,8 +21,7 @@ class GuiBatchRunResultRowTest {
|
||||
|
||||
private static final DocumentFingerprint FP =
|
||||
new DocumentFingerprint("a".repeat(64));
|
||||
private static final DocumentFingerprint FP2 =
|
||||
new DocumentFingerprint("b".repeat(64));
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Basic construction
|
||||
@@ -95,27 +94,35 @@ class GuiBatchRunResultRowTest {
|
||||
|
||||
@Test
|
||||
void statusIcon_success_isCheckMark() {
|
||||
assertEquals("\u2714", row(DocumentCompletionStatus.SUCCESS).statusIcon());
|
||||
assertEquals("✓", row(DocumentCompletionStatus.SUCCESS).statusIcon());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusIcon_failedRetryable_isWarning() {
|
||||
assertEquals("\u26A0", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
|
||||
assertEquals("↻", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusIcon_failedPermanent_isBallotX() {
|
||||
assertEquals("\u2718", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
|
||||
assertEquals("×", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusIcon_skippedAlreadyProcessed_isPointer() {
|
||||
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
|
||||
void statusIcon_skippedAlreadyProcessed_isNextTrack() {
|
||||
assertEquals("≡", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusIcon_skippedFinalFailure_isPointer() {
|
||||
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon());
|
||||
void statusIcon_skippedFinalFailure_isCircledDivisionSlash() {
|
||||
assertEquals("⊘", row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusIcon_skippedValues_areDifferentFromEachOther() {
|
||||
String alreadyProcessed = row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon();
|
||||
String finalFailure = row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon();
|
||||
assertFalse(alreadyProcessed.equals(finalFailure),
|
||||
"SKIPPED_ALREADY_PROCESSED und SKIPPED_FINAL_FAILURE müssen unterschiedliche Icons haben");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -181,6 +188,50 @@ class GuiBatchRunResultRowTest {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// statusTooltip
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void statusTooltip_success_isNonBlank() {
|
||||
assertFalse(row(DocumentCompletionStatus.SUCCESS).statusTooltip().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusTooltip_failedRetryable_isNonBlank() {
|
||||
assertFalse(row(DocumentCompletionStatus.FAILED_RETRYABLE).statusTooltip().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusTooltip_failedPermanent_isNonBlank() {
|
||||
assertFalse(row(DocumentCompletionStatus.FAILED_PERMANENT).statusTooltip().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusTooltip_failedRetryable_and_failedPermanent_areDifferent() {
|
||||
String retryable = row(DocumentCompletionStatus.FAILED_RETRYABLE).statusTooltip();
|
||||
String permanent = row(DocumentCompletionStatus.FAILED_PERMANENT).statusTooltip();
|
||||
assertFalse(retryable.equals(permanent),
|
||||
"FAILED_RETRYABLE und FAILED_PERMANENT müssen unterschiedliche Tooltips haben");
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusTooltip_allStatuses_delegatesToProcessingStatusPresentation() {
|
||||
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
|
||||
String rowTooltip = row(status).statusTooltip();
|
||||
String expectedTooltip = ProcessingStatusPresentation.tooltipFor(status);
|
||||
assertEquals(expectedTooltip, rowTooltip,
|
||||
"statusTooltip() soll Wert von ProcessingStatusPresentation liefern für " + status);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusTooltip_resetPending_returnsResetLabel() {
|
||||
GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(
|
||||
row(DocumentCompletionStatus.SUCCESS));
|
||||
assertEquals(GuiBatchRunResultRow.RESET_PENDING_LABEL, marker.statusTooltip());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// aiFailureMessage
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+2
-2
@@ -140,10 +140,10 @@ class GuiBatchRunTabSmokeTest {
|
||||
tab().resultTable().getSelectionModel().select(1);
|
||||
assertTrue(tab().detailArea().getText().contains(GuiBatchRunTab.NO_REASONING_TEXT));
|
||||
|
||||
// SKIPPED row must carry the ► icon, not ✘.
|
||||
// SKIPPED_ALREADY_PROCESSED trägt das Identisch-Icon ≡, nicht ⏭.
|
||||
GuiBatchRunResultRow skippedRow = tab().resultTable().getItems().get(2);
|
||||
assertEquals(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, skippedRow.status());
|
||||
assertEquals("\u25BA", skippedRow.statusIcon());
|
||||
assertEquals("≡", skippedRow.statusIcon());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+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
@@ -1,7 +1,6 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation.StatusVisuals;
|
||||
|
||||
/**
|
||||
* Unit-Tests für {@link ProcessingStatusPresentation}.
|
||||
* <p>
|
||||
* Prüft, dass alle {@link DocumentCompletionStatus}-Werte korrekte Icons, Farben,
|
||||
* Tooltip-Texte und Summary-Kategorielabels liefern und dass keine zwei Status
|
||||
* dasselbe Icon teilen.
|
||||
*/
|
||||
class ProcessingStatusPresentationTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// iconFor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void iconFor_success_isCheckMark() {
|
||||
assertEquals(ProcessingStatusPresentation.ICON_SUCCESS,
|
||||
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SUCCESS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void iconFor_failedRetryable_isClockwiseArrow() {
|
||||
assertEquals(ProcessingStatusPresentation.ICON_FAILED_RETRYABLE,
|
||||
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void iconFor_failedPermanent_isMultiplicationSign() {
|
||||
assertEquals(ProcessingStatusPresentation.ICON_FAILED_PERMANENT,
|
||||
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void iconFor_skippedAlreadyProcessed_isIdenticalTo() {
|
||||
assertEquals(ProcessingStatusPresentation.ICON_SKIPPED_ALREADY_PROCESSED,
|
||||
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void iconFor_skippedFinalFailure_isCircledDivisionSlash() {
|
||||
assertEquals(ProcessingStatusPresentation.ICON_SKIPPED_FINAL_FAILURE,
|
||||
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void iconFor_null_throws() {
|
||||
assertThrows(NullPointerException.class,
|
||||
() -> ProcessingStatusPresentation.iconFor(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void icons_areAllDistinct() {
|
||||
Set<String> icons = new HashSet<>();
|
||||
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
|
||||
icons.add(ProcessingStatusPresentation.iconFor(status));
|
||||
}
|
||||
assertEquals(DocumentCompletionStatus.values().length, icons.size(),
|
||||
"Jeder Status muss ein eindeutiges Icon haben");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// cssColorFor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(DocumentCompletionStatus.class)
|
||||
void cssColorFor_allStatuses_returnsNonBlankHexColor(DocumentCompletionStatus status) {
|
||||
String color = ProcessingStatusPresentation.cssColorFor(status);
|
||||
assertAll(
|
||||
() -> assertNotNull(color, "Farbe darf nicht null sein für " + status),
|
||||
() -> assertFalse(color.isBlank(), "Farbe darf nicht leer sein für " + status),
|
||||
() -> assertFalse(color.isEmpty(), "Farbe darf nicht leer sein für " + status)
|
||||
);
|
||||
// Farbe muss im CSS-Hex-Format beginnen (#)
|
||||
assertFalse(color.isBlank());
|
||||
assertEquals('#', color.charAt(0), "CSS-Farbe muss mit # beginnen für " + status);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cssColorFor_null_throws() {
|
||||
assertThrows(NullPointerException.class,
|
||||
() -> ProcessingStatusPresentation.cssColorFor(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRetryable_and_failedPermanent_haveDifferentColors() {
|
||||
String orangeColor = ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_RETRYABLE);
|
||||
String redColor = ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_PERMANENT);
|
||||
assertFalse(orangeColor.equals(redColor),
|
||||
"FAILED_RETRYABLE (Orange) und FAILED_PERMANENT (Rot) müssen unterschiedliche Farben haben");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// tooltipFor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(DocumentCompletionStatus.class)
|
||||
void tooltipFor_allStatuses_returnsNonBlankText(DocumentCompletionStatus status) {
|
||||
String tooltip = ProcessingStatusPresentation.tooltipFor(status);
|
||||
assertNotNull(tooltip, "Tooltip darf nicht null sein für " + status);
|
||||
assertFalse(tooltip.isBlank(), "Tooltip darf nicht leer sein für " + status);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooltipFor_null_throws() {
|
||||
assertThrows(NullPointerException.class,
|
||||
() -> ProcessingStatusPresentation.tooltipFor(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooltipFor_failedRetryable_containsWiederholung() {
|
||||
String tooltip = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_RETRYABLE);
|
||||
assertFalse(tooltip.isBlank());
|
||||
// Tooltip muss die Retry-Semantik kommunizieren
|
||||
assertFalse(tooltip.equals(ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT)),
|
||||
"FAILED_RETRYABLE und FAILED_PERMANENT müssen unterschiedliche Tooltips haben");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooltipFor_failedPermanent_containsKeinWeitererVersuch() {
|
||||
String tooltip = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT);
|
||||
// Tooltip für FAILED_PERMANENT muss kommunizieren, dass kein weiterer automatischer Versuch folgt
|
||||
assertFalse(tooltip.isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooltips_areAllDistinct() {
|
||||
Set<String> tooltips = new HashSet<>();
|
||||
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
|
||||
tooltips.add(ProcessingStatusPresentation.tooltipFor(status));
|
||||
}
|
||||
assertEquals(DocumentCompletionStatus.values().length, tooltips.size(),
|
||||
"Jeder Status muss einen eindeutigen Tooltip haben");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// summaryCategoryFor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(DocumentCompletionStatus.class)
|
||||
void summaryCategoryFor_allStatuses_returnsNonBlankLabel(DocumentCompletionStatus status) {
|
||||
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
|
||||
assertNotNull(category, "Summary-Kategorie darf nicht null sein für " + status);
|
||||
assertFalse(category.isBlank(), "Summary-Kategorie darf nicht leer sein für " + status);
|
||||
}
|
||||
|
||||
@Test
|
||||
void summaryCategoryFor_null_throws() {
|
||||
assertThrows(NullPointerException.class,
|
||||
() -> ProcessingStatusPresentation.summaryCategoryFor(null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// visualsFor (gebündelt)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(DocumentCompletionStatus.class)
|
||||
void visualsFor_allStatuses_returnsConsistentRecord(DocumentCompletionStatus status) {
|
||||
StatusVisuals visuals = ProcessingStatusPresentation.visualsFor(status);
|
||||
assertAll(
|
||||
() -> assertNotNull(visuals, "StatusVisuals darf nicht null sein für " + status),
|
||||
() -> assertEquals(ProcessingStatusPresentation.iconFor(status), visuals.icon()),
|
||||
() -> assertEquals(ProcessingStatusPresentation.cssColorFor(status), visuals.cssColor()),
|
||||
() -> assertEquals(ProcessingStatusPresentation.tooltipFor(status), visuals.tooltipText()),
|
||||
() -> assertEquals(ProcessingStatusPresentation.summaryCategoryFor(status),
|
||||
visuals.summaryCategoryLabel())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void visualsFor_null_throws() {
|
||||
assertThrows(NullPointerException.class,
|
||||
() -> ProcessingStatusPresentation.visualsFor(null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Spezifische Status-Mapping-Werte (gemäß Spezifikation)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void success_mapping_correctValues() {
|
||||
assertAll(
|
||||
() -> assertEquals("✓", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SUCCESS)),
|
||||
() -> assertEquals("#2e7d32", ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.SUCCESS)),
|
||||
() -> assertEquals("erfolgreich",
|
||||
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.SUCCESS))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRetryable_mapping_correctValues() {
|
||||
assertAll(
|
||||
() -> assertEquals("↻", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE)),
|
||||
() -> assertEquals("#d98200",
|
||||
ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_RETRYABLE)),
|
||||
() -> assertEquals("wird wiederholt",
|
||||
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.FAILED_RETRYABLE))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPermanent_mapping_correctValues() {
|
||||
assertAll(
|
||||
() -> assertEquals("×", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT)),
|
||||
() -> assertEquals("#c62828",
|
||||
ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_PERMANENT)),
|
||||
() -> assertEquals("fehlgeschlagen",
|
||||
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.FAILED_PERMANENT))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skippedAlreadyProcessed_mapping_correctValues() {
|
||||
assertAll(
|
||||
() -> assertEquals("≡",
|
||||
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED)),
|
||||
() -> assertEquals("übersprungen",
|
||||
ProcessingStatusPresentation.summaryCategoryFor(
|
||||
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skippedFinalFailure_mapping_correctValues() {
|
||||
assertAll(
|
||||
() -> assertEquals("⊘",
|
||||
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE)),
|
||||
() -> assertEquals("endgültig übersprungen",
|
||||
ProcessingStatusPresentation.summaryCategoryFor(
|
||||
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE))
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Farbe ist NICHT einziges Unterscheidungsmerkmal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void failedRetryable_and_failedPermanent_distinctByIconAndTooltip() {
|
||||
String iconRetryable = ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE);
|
||||
String iconPermanent = ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT);
|
||||
String tooltipRetryable = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_RETRYABLE);
|
||||
String tooltipPermanent = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT);
|
||||
assertAll(
|
||||
() -> assertFalse(iconRetryable.equals(iconPermanent),
|
||||
"Icons müssen sich unterscheiden"),
|
||||
() -> assertFalse(tooltipRetryable.equals(tooltipPermanent),
|
||||
"Tooltips müssen sich unterscheiden")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<parent>
|
||||
<groupId>de.gecheckt</groupId>
|
||||
<artifactId>pdf-umbenenner-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>pdf-umbenenner-adapter-out</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
@@ -31,6 +31,10 @@
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
@@ -48,8 +52,7 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-slf4j-impl</artifactId>
|
||||
<version>${log4j.version}</version>
|
||||
<artifactId>log4j-slf4j2-impl</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
+10
-6
@@ -95,6 +95,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
|
||||
* </ul>
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -248,20 +252,20 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
|
||||
JSONArray choices = json.optJSONArray("choices");
|
||||
if (choices == null || choices.isEmpty()) {
|
||||
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");
|
||||
}
|
||||
JSONObject firstChoice = choices.getJSONObject(0);
|
||||
JSONObject message = firstChoice.optJSONObject("message");
|
||||
if (message == null) {
|
||||
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");
|
||||
}
|
||||
String content = message.optString("content", null);
|
||||
String content = message.optString(JSON_KEY_CONTENT, null);
|
||||
if (content == null || content.isBlank()) {
|
||||
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");
|
||||
}
|
||||
return new AiInvocationSuccess(request, new AiRawResponse(content));
|
||||
@@ -347,11 +351,11 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
|
||||
|
||||
JSONObject systemMessage = new JSONObject();
|
||||
systemMessage.put("role", "system");
|
||||
systemMessage.put("content", request.promptContent());
|
||||
systemMessage.put(JSON_KEY_CONTENT, request.promptContent());
|
||||
|
||||
JSONObject userMessage = new JSONObject();
|
||||
userMessage.put("role", "user");
|
||||
userMessage.put("content", request.documentText());
|
||||
userMessage.put(JSON_KEY_CONTENT, request.documentText());
|
||||
|
||||
body.put("messages", new org.json.JSONArray()
|
||||
.put(systemMessage)
|
||||
|
||||
+16
-5
@@ -1,6 +1,8 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.fingerprint;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.DigestInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
@@ -45,7 +47,7 @@ public class Sha256FingerprintAdapter implements FingerprintPort {
|
||||
* The implementation:
|
||||
* <ol>
|
||||
* <li>Resolves the opaque locator to a filesystem path</li>
|
||||
* <li>Reads the entire file content</li>
|
||||
* <li>Reads the file content in chunks via a streaming digest</li>
|
||||
* <li>Applies SHA-256 hashing</li>
|
||||
* <li>Returns the hex-encoded result wrapped in a {@link FingerprintSuccess}</li>
|
||||
* </ol>
|
||||
@@ -113,8 +115,9 @@ public class Sha256FingerprintAdapter implements FingerprintPort {
|
||||
/**
|
||||
* Computes the SHA-256 hash of the file content at the given path.
|
||||
* <p>
|
||||
* Reads the entire file content and applies SHA-256 hashing to produce
|
||||
* a lowercase hexadecimal representation of the digest.
|
||||
* Liest die Datei blockweise über einen {@link DigestInputStream}, um den Heap-Bedarf
|
||||
* bei großen PDFs zu minimieren. Das erzeugte Hash-Ergebnis ist bitidentisch zur
|
||||
* byteweisen Verarbeitung des gesamten Dateiinhalts.
|
||||
*
|
||||
* @param filePath the path to the file to hash; must not be null
|
||||
* @return the lowercase hexadecimal representation of the SHA-256 digest (64 characters)
|
||||
@@ -123,8 +126,16 @@ public class Sha256FingerprintAdapter implements FingerprintPort {
|
||||
*/
|
||||
private String computeSha256Hash(Path filePath) throws IOException, NoSuchAlgorithmException {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] fileBytes = Files.readAllBytes(filePath);
|
||||
byte[] hashBytes = digest.digest(fileBytes);
|
||||
// Streaming-Verarbeitung: Die Datei wird in 8-KB-Blöcken gelesen, damit auch
|
||||
// sehr große PDFs nicht vollständig in den Heap geladen werden müssen.
|
||||
byte[] buf = new byte[8192];
|
||||
try (InputStream is = Files.newInputStream(filePath);
|
||||
DigestInputStream dis = new DigestInputStream(is, digest)) {
|
||||
while (dis.read(buf) != -1) {
|
||||
// DigestInputStream leitet jeden Block automatisch an den MessageDigest weiter
|
||||
}
|
||||
}
|
||||
byte[] hashBytes = digest.digest();
|
||||
return bytesToHex(hashBytes);
|
||||
}
|
||||
|
||||
|
||||
+84
-19
@@ -4,22 +4,26 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
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.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.RunLockUnavailableException;
|
||||
|
||||
/**
|
||||
* File-based implementation of {@link RunLockPort} that uses a lock file to prevent concurrent runs.
|
||||
* Dateibasierte Implementierung von {@link RunLockPort}.
|
||||
* <p>
|
||||
* Creates an exclusive lock file on acquire and deletes it on release.
|
||||
* If the lock file already exists, {@link #acquire()} throws {@link RunLockUnavailableException}
|
||||
* to signal that another instance is already running.
|
||||
* Verwendet eine Lock-Datei, um parallele Läufe zu verhindern.
|
||||
* Beim Erwerb wird die Lock-Datei angelegt; bei der Freigabe wird sie gelöscht.
|
||||
* Existiert die Datei bereits, ist der Lock belegt.
|
||||
* <p>
|
||||
* The lock file contains the PID of the acquiring process. Release is best-effort: a failure
|
||||
* to delete the lock file is logged as a warning but does not throw.
|
||||
* Die Lock-Datei enthält die PID des erwerbenden Prozesses.
|
||||
* Die Freigabe ist best-effort: Ein Fehler beim Löschen wird als Warnung
|
||||
* geloggt, wirft aber keine Ausnahme.
|
||||
*/
|
||||
public class FilesystemRunLockPortAdapter implements RunLockPort {
|
||||
|
||||
@@ -28,27 +32,31 @@ public class FilesystemRunLockPortAdapter implements RunLockPort {
|
||||
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) {
|
||||
this.lockFile = lockFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires the run lock by creating the lock file.
|
||||
* Erwirbt den Run-Lock durch Anlegen der Lock-Datei (blockierend).
|
||||
* <p>
|
||||
* If the lock file already exists, throws {@link RunLockUnavailableException}.
|
||||
* If the parent directory does not exist, it is created before attempting file creation.
|
||||
* Existiert die Lock-Datei bereits, wird eine
|
||||
* {@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
|
||||
public void acquire() {
|
||||
if (Files.exists(lockFile)) {
|
||||
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 {
|
||||
Path parent = lockFile.getParent();
|
||||
@@ -57,26 +65,83 @@ public class FilesystemRunLockPortAdapter implements RunLockPort {
|
||||
}
|
||||
long pid = ProcessHandle.current().pid();
|
||||
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) {
|
||||
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>
|
||||
* 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
|
||||
public void release() {
|
||||
try {
|
||||
boolean deleted = Files.deleteIfExists(lockFile);
|
||||
if (deleted) {
|
||||
LOG.debug("Run lock released: {}", lockFile);
|
||||
LOG.debug("Run-Lock freigegeben: {}", lockFile);
|
||||
}
|
||||
} 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>
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -133,28 +137,28 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
|
||||
|
||||
} catch (java.net.http.HttpTimeoutException e) {
|
||||
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());
|
||||
} catch (java.net.ConnectException e) {
|
||||
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());
|
||||
} catch (java.net.UnknownHostException e) {
|
||||
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());
|
||||
} catch (java.io.IOException e) {
|
||||
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());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
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.");
|
||||
} catch (Exception 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());
|
||||
}
|
||||
}
|
||||
@@ -188,7 +192,7 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
|
||||
|
||||
if (status != 200) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -291,24 +295,24 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
|
||||
return handleResponse(response);
|
||||
|
||||
} 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());
|
||||
} 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());
|
||||
} 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());
|
||||
} 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());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||
"Modellabruf wurde unterbrochen.");
|
||||
} catch (Exception 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());
|
||||
}
|
||||
}
|
||||
|
||||
+17
-13
@@ -46,6 +46,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalog
|
||||
* </ul>
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -129,28 +133,28 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
|
||||
|
||||
} catch (java.net.http.HttpTimeoutException e) {
|
||||
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());
|
||||
} catch (java.net.ConnectException e) {
|
||||
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());
|
||||
} catch (java.net.UnknownHostException e) {
|
||||
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());
|
||||
} catch (java.io.IOException e) {
|
||||
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());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
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.");
|
||||
} catch (Exception 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());
|
||||
}
|
||||
}
|
||||
@@ -184,7 +188,7 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
|
||||
|
||||
if (status != 200) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -285,24 +289,24 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
|
||||
return handleResponse(response);
|
||||
|
||||
} 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());
|
||||
} 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());
|
||||
} 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());
|
||||
} 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());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||
"Modellabruf wurde unterbrochen.");
|
||||
} catch (Exception 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());
|
||||
}
|
||||
}
|
||||
|
||||
+115
-23
@@ -2,9 +2,12 @@ package de.gecheckt.pdf.umbenenner.adapter.out.prompt;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -13,45 +16,61 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||
|
||||
/**
|
||||
* Filesystem-based implementation of {@link PromptPort}.
|
||||
* Dateisystembasierte Implementierung von {@link PromptPort}.
|
||||
* <p>
|
||||
* Loads prompt templates from an external file on disk and derives a stable identifier
|
||||
* from the filename. Ensures that empty or technically unusable prompts are rejected.
|
||||
* Lädt Prompt-Templates aus einer externen Datei auf dem Datenträger und leitet einen
|
||||
* stabilen Identifikator aus dem Dateinamen ab. Stellt sicher, dass leere oder technisch
|
||||
* unbrauchbare Prompts abgelehnt werden.
|
||||
* <p>
|
||||
* <strong>Identifier derivation:</strong>
|
||||
* The stable prompt identifier is derived from the filename of the prompt file.
|
||||
* This ensures deterministic, reproducible identification across batch runs.
|
||||
* For example, a prompt file named {@code "prompt_de_v2.txt"} receives the identifier
|
||||
* <strong>Identifikatorableitung:</strong>
|
||||
* Der stabile Identifikator wird aus dem Dateinamen der Prompt-Datei abgeleitet.
|
||||
* Eine Prompt-Datei namens {@code "prompt_de_v2.txt"} erhält den Identifikator
|
||||
* {@code "prompt_de_v2.txt"}.
|
||||
* <p>
|
||||
* <strong>Content validation:</strong>
|
||||
* After loading, the prompt content is trimmed and validated to ensure it is not empty.
|
||||
* An empty prompt (or one containing only whitespace) is considered technically unusable
|
||||
* and results in a {@link PromptLoadingFailure}.
|
||||
* <strong>Inhaltsprüfung:</strong>
|
||||
* Nach dem Laden wird der Inhalt getrimmt und auf Leerheit geprüft. Ein leerer Prompt
|
||||
* (oder einer, der nur Leerzeichen enthält) gilt als technisch unbrauchbar und führt zu
|
||||
* {@link PromptLoadingFailure}.
|
||||
* <p>
|
||||
* <strong>Error handling:</strong>
|
||||
* All technical failures (file not found, I/O errors, permission issues) are caught
|
||||
* and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
|
||||
* <strong>Atomares Speichern:</strong>
|
||||
* {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei <em>im selben
|
||||
* Verzeichnis</em> wie die Zieldatei und verschiebt diese danach atomar via
|
||||
* {@code ATOMIC_MOVE}. Bei einem Fehler beim atomaren Verschieben wird kein stiller
|
||||
* Fallback auf nicht-atomares Schreiben durchgeführt.
|
||||
* <p>
|
||||
* <strong>Fehlerbehandlung:</strong>
|
||||
* Alle technischen Fehler (Datei nicht gefunden, I/O-Fehler, fehlende Berechtigungen)
|
||||
* werden abgefangen und als strukturierte Ergebnistypen zurückgegeben – keine Exceptions
|
||||
* werden propagiert.
|
||||
*/
|
||||
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 final Path promptFilePath;
|
||||
|
||||
/**
|
||||
* Creates the adapter with the configured prompt file path.
|
||||
* Erstellt den Adapter mit dem konfigurierten Pfad zur Prompt-Datei.
|
||||
*
|
||||
* @param promptFilePath the path to the prompt template file; must not be null
|
||||
* @throws NullPointerException if promptFilePath is null
|
||||
* @param promptFilePath Pfad zur Prompt-Template-Datei; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code promptFilePath} null ist
|
||||
*/
|
||||
public FilesystemPromptPortAdapter(Path promptFilePath) {
|
||||
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das konfigurierte Prompt-Template aus der Datei.
|
||||
*
|
||||
* @return {@link PromptLoadingResult} mit dem geladenen Inhalt oder einem klassifizierten Fehler;
|
||||
* nie {@code null}
|
||||
*/
|
||||
@Override
|
||||
public PromptLoadingResult loadPrompt() {
|
||||
try {
|
||||
@@ -71,11 +90,11 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
||||
}
|
||||
|
||||
PromptIdentifier identifier = deriveIdentifier();
|
||||
LOG.debug("Prompt loaded successfully from {}", promptFilePath);
|
||||
LOG.debug("Prompt erfolgreich geladen von {}", promptFilePath);
|
||||
return new PromptLoadingSuccess(identifier, trimmedContent);
|
||||
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load prompt file: {}", promptFilePath, e);
|
||||
LOG.error("Fehler beim Laden der Prompt-Datei: {}", promptFilePath, e);
|
||||
return new PromptLoadingFailure(
|
||||
"IO_ERROR",
|
||||
"Failed to read prompt file: " + e.getMessage());
|
||||
@@ -83,15 +102,88 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable prompt identifier from the filename.
|
||||
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
|
||||
* <p>
|
||||
* The identifier is simply the filename (without the directory path).
|
||||
* This ensures that the same prompt file always receives the same identifier.
|
||||
* Der Ablauf:
|
||||
* <ol>
|
||||
* <li>Prüfen, ob der Zielordner existiert.</li>
|
||||
* <li>Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen.</li>
|
||||
* <li>Inhalt in UTF-8 in die temporäre Datei schreiben.</li>
|
||||
* <li>Temporäre Datei via {@code ATOMIC_MOVE} zur Zieldatei verschieben.</li>
|
||||
* <li>Bei Fehler: temporäre Datei aufräumen, Fehler als Ergebnis zurückgeben.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Zeilenenden werden unverändert übernommen. Es findet keine Normalisierung statt.
|
||||
*
|
||||
* @return a stable PromptIdentifier based on the filename
|
||||
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
|
||||
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code content} null ist
|
||||
*/
|
||||
@Override
|
||||
public PromptSaveResult savePrompt(String content) {
|
||||
Objects.requireNonNull(content, "content must not be null");
|
||||
|
||||
Path targetDir = promptFilePath.getParent();
|
||||
if (targetDir == null || !Files.isDirectory(targetDir)) {
|
||||
String message = "Zielordner der Prompt-Datei existiert nicht: "
|
||||
+ (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
|
||||
LOG.warn(SAVE_FAILED_LOG_MSG, message);
|
||||
return new PromptSaveResult.TargetDirectoryMissing(message);
|
||||
}
|
||||
|
||||
// Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen
|
||||
// (nicht im System-Temp – ATOMIC_MOVE funktioniert nicht zuverlässig über Dateisystem-Grenzen)
|
||||
Path tempFile = targetDir.resolve(".prompt-tmp-" + UUID.randomUUID() + ".tmp");
|
||||
|
||||
try {
|
||||
Files.write(tempFile, content.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
beräumeTempDatei(tempFile);
|
||||
String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
|
||||
LOG.warn(SAVE_FAILED_LOG_MSG, message, e);
|
||||
return new PromptSaveResult.WriteFailed(message, e);
|
||||
}
|
||||
|
||||
// Atomares Verschieben – kein stiller Fallback auf nicht-atomares Move
|
||||
try {
|
||||
Files.move(tempFile, promptFilePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
LOG.info("Prompt-Datei erfolgreich gespeichert: {}", promptFilePath.toAbsolutePath());
|
||||
return new PromptSaveResult.Saved(promptFilePath.toAbsolutePath().toString());
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
beräumeTempDatei(tempFile);
|
||||
String message = "Atomares Verschieben der Prompt-Datei wird vom Dateisystem nicht unterstützt: " + e.getMessage();
|
||||
LOG.warn("Prompt speichern fehlgeschlagen (kein Fallback): {}", message, e);
|
||||
return new PromptSaveResult.AtomicMoveFailed(message);
|
||||
} catch (IOException e) {
|
||||
beräumeTempDatei(tempFile);
|
||||
String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
|
||||
LOG.warn(SAVE_FAILED_LOG_MSG, message, e);
|
||||
return new PromptSaveResult.AtomicMoveFailed(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet den stabilen Prompt-Identifikator aus dem Dateinamen ab.
|
||||
* <p>
|
||||
* Der Identifikator entspricht dem Dateinamen ohne Verzeichnispfad.
|
||||
*
|
||||
* @return stabiler {@link PromptIdentifier} basierend auf dem Dateinamen
|
||||
*/
|
||||
private PromptIdentifier deriveIdentifier() {
|
||||
String filename = promptFilePath.getFileName().toString();
|
||||
return new PromptIdentifier(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht, die temporäre Datei zu löschen. Fehler werden nur geloggt.
|
||||
*
|
||||
* @param tempFile die zu löschende temporäre Datei
|
||||
*/
|
||||
private void beräumeTempDatei(Path tempFile) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException ex) {
|
||||
LOG.warn("Temporäre Prompt-Datei konnte nicht gelöscht werden: {}", tempFile, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -43,6 +43,8 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceC
|
||||
* Ausnahmen an den Aufrufer weitergegeben.
|
||||
*/
|
||||
public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
||||
private static final String INVALID_PATH_PREFIX = "Ungültiger Pfad: ";
|
||||
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class);
|
||||
|
||||
@@ -66,7 +68,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
||||
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
|
||||
Path path = toPath(suggestion.path());
|
||||
if (path == null) {
|
||||
String msg = "Ungültiger Pfad: " + suggestion.path();
|
||||
String msg = INVALID_PATH_PREFIX + suggestion.path();
|
||||
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
|
||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||
}
|
||||
@@ -114,7 +116,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
||||
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||
Path path = toPath(suggestion.path());
|
||||
if (path == null) {
|
||||
String msg = "Ungültiger Pfad: " + suggestion.path();
|
||||
String msg = INVALID_PATH_PREFIX + suggestion.path();
|
||||
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg);
|
||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||
}
|
||||
@@ -164,7 +166,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
||||
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||
Path path = toPath(suggestion.path());
|
||||
if (path == null) {
|
||||
String msg = "Ungültiger Pfad: " + suggestion.path();
|
||||
String msg = INVALID_PATH_PREFIX + suggestion.path();
|
||||
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", 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;
|
||||
}
|
||||
}
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
||||
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.HistoryQuery;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||
|
||||
/**
|
||||
* SQLite-Implementierung von {@link HistoryQueryPort}.
|
||||
* <p>
|
||||
* Kapselt alle lesenden Datenbankoperationen für den Historien-Tab.
|
||||
* Sämtliche JDBC-Details sind strikt in dieser Klasse eingeschlossen;
|
||||
* keine JDBC-Typen erscheinen im Port-Interface oder in Domänen-/Application-Typen.
|
||||
* <p>
|
||||
* <strong>Suche:</strong> Freitextsuche ist case-insensitiv (via {@code LOWER()}).
|
||||
* Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem
|
||||
* SQL-LIKE-Aufruf mit {@code \} escaped.
|
||||
* <p>
|
||||
* <strong>Sortierung:</strong> Standard absteigend nach {@code updated_at},
|
||||
* Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar).
|
||||
* <p>
|
||||
* <strong>Limit:</strong> Wird direkt als SQL-{@code LIMIT} angewendet.
|
||||
* Ein Limit von 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr
|
||||
* als 500 Treffer vorhanden sind.
|
||||
*/
|
||||
public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(SqliteHistoryQueryAdapter.class);
|
||||
|
||||
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
/**
|
||||
* Erzeugt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
|
||||
*
|
||||
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
|
||||
* @throws NullPointerException wenn {@code jdbcUrl} null ist
|
||||
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
|
||||
*/
|
||||
public SqliteHistoryQueryAdapter(String jdbcUrl) {
|
||||
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
|
||||
if (jdbcUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
|
||||
}
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* <p>
|
||||
* Die SQL-Abfrage aggregiert die Versuchsanzahl per {@code COUNT}-Subquery.
|
||||
* Freitextsuche und Status-Filter werden als optionale WHERE-Klauseln ergänzt.
|
||||
*
|
||||
* @param query Abfrageparameter; darf nicht {@code null} sein
|
||||
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
*/
|
||||
@Override
|
||||
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||
Objects.requireNonNull(query, "query darf nicht null sein");
|
||||
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT
|
||||
dr.fingerprint,
|
||||
dr.overall_status,
|
||||
dr.last_known_source_file_name,
|
||||
dr.last_target_file_name,
|
||||
dr.last_known_source_locator,
|
||||
dr.updated_at,
|
||||
(SELECT COUNT(*) FROM processing_attempt pa WHERE pa.fingerprint = dr.fingerprint) AS attempt_count
|
||||
FROM document_record dr
|
||||
WHERE 1=1
|
||||
""");
|
||||
|
||||
List<Object> params = new ArrayList<>();
|
||||
|
||||
// Freitextsuche: case-insensitiv über Quelldateiname und Zieldateiname
|
||||
String searchText = query.searchText();
|
||||
if (searchText != null && !searchText.isBlank()) {
|
||||
String escaped = escapeSqlLike(searchText.strip().toLowerCase());
|
||||
sql.append(" AND (LOWER(dr.last_known_source_file_name) LIKE ? ESCAPE '\\' "
|
||||
+ "OR LOWER(dr.last_target_file_name) LIKE ? ESCAPE '\\')");
|
||||
String pattern = "%" + escaped + "%";
|
||||
params.add(pattern);
|
||||
params.add(pattern);
|
||||
}
|
||||
|
||||
// Status-Filter
|
||||
String statusFilter = query.statusFilter();
|
||||
if (statusFilter != null && !statusFilter.isBlank()) {
|
||||
sql.append(" AND dr.overall_status = ?");
|
||||
params.add(statusFilter.strip());
|
||||
}
|
||||
|
||||
sql.append(" ORDER BY dr.updated_at DESC, dr.fingerprint ASC");
|
||||
sql.append(" LIMIT ?");
|
||||
params.add(query.limit());
|
||||
|
||||
try (Connection connection = getConnection();
|
||||
Statement pragmaStmt = connection.createStatement();
|
||||
PreparedStatement stmt = connection.prepareStatement(sql.toString())) {
|
||||
|
||||
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||
|
||||
for (int i = 0; i < params.size(); i++) {
|
||||
stmt.setObject(i + 1, params.get(i));
|
||||
}
|
||||
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
List<DocumentHistoryRow> rows = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
rows.add(mapToDocumentHistoryRow(rs));
|
||||
}
|
||||
logger.debug("Historien-Übersicht geladen: {} Zeilen (Limit {})", rows.size(), query.limit());
|
||||
return List.copyOf(rows);
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Historien-Übersicht konnte nicht geladen werden: " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
*/
|
||||
@Override
|
||||
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
last_known_source_locator,
|
||||
last_known_source_file_name,
|
||||
overall_status,
|
||||
content_error_count,
|
||||
transient_error_count,
|
||||
last_failure_instant,
|
||||
last_success_instant,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_target_path,
|
||||
last_target_file_name
|
||||
FROM document_record
|
||||
WHERE fingerprint = ?
|
||||
""";
|
||||
|
||||
try (Connection connection = getConnection();
|
||||
PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||
|
||||
stmt.setString(1, fingerprint.sha256Hex());
|
||||
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return Optional.of(mapToDocumentRecord(rs, fingerprint));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Dokument-Stammsatz konnte nicht geladen werden für Fingerprint '"
|
||||
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @return unveränderliche Liste der Versuche aufsteigend nach {@code attempt_number};
|
||||
* nie {@code null}; kann leer sein
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
*/
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
fingerprint, run_id, attempt_number, started_at, ended_at,
|
||||
status, failure_class, failure_message, retryable,
|
||||
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
|
||||
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
|
||||
final_target_file_name
|
||||
FROM processing_attempt
|
||||
WHERE fingerprint = ?
|
||||
ORDER BY attempt_number ASC
|
||||
""";
|
||||
|
||||
try (Connection connection = getConnection();
|
||||
Statement pragmaStmt = connection.createStatement();
|
||||
PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||
|
||||
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||
stmt.setString(1, fingerprint.sha256Hex());
|
||||
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
List<ProcessingAttempt> attempts = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
attempts.add(mapToProcessingAttempt(rs));
|
||||
}
|
||||
return List.copyOf(attempts);
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Verarbeitungsversuche konnten nicht geladen werden für Fingerprint '"
|
||||
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mapping-Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bildet eine ResultSet-Zeile auf eine {@link DocumentHistoryRow} ab.
|
||||
*
|
||||
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||
* @return die gemappte Zeile; nie {@code null}
|
||||
* @throws SQLException bei JDBC-Lesefehlern
|
||||
*/
|
||||
private DocumentHistoryRow mapToDocumentHistoryRow(ResultSet rs) throws SQLException {
|
||||
String fpHex = rs.getString("fingerprint");
|
||||
String statusStr = rs.getString("overall_status");
|
||||
String sourceFileName = rs.getString("last_known_source_file_name");
|
||||
String targetFileName = rs.getString("last_target_file_name"); // nullable
|
||||
String sourcePath = rs.getString("last_known_source_locator");
|
||||
String updatedAtStr = rs.getString("updated_at");
|
||||
long attemptCount = rs.getLong("attempt_count");
|
||||
|
||||
return new DocumentHistoryRow(
|
||||
new DocumentFingerprint(fpHex),
|
||||
ProcessingStatus.valueOf(statusStr),
|
||||
sourceFileName,
|
||||
targetFileName,
|
||||
sourcePath,
|
||||
stringToInstant(updatedAtStr),
|
||||
attemptCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet eine ResultSet-Zeile auf einen {@link DocumentRecord} ab.
|
||||
*
|
||||
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||
* @param fingerprint der Fingerprint, der bereits bekannt ist
|
||||
* @return der gemappte Stammsatz; nie {@code null}
|
||||
* @throws SQLException bei JDBC-Lesefehlern
|
||||
*/
|
||||
private DocumentRecord mapToDocumentRecord(ResultSet rs, DocumentFingerprint fingerprint) throws SQLException {
|
||||
return new DocumentRecord(
|
||||
fingerprint,
|
||||
new SourceDocumentLocator(rs.getString("last_known_source_locator")),
|
||||
rs.getString("last_known_source_file_name"),
|
||||
ProcessingStatus.valueOf(rs.getString("overall_status")),
|
||||
new FailureCounters(
|
||||
rs.getInt("content_error_count"),
|
||||
rs.getInt("transient_error_count")),
|
||||
stringToInstant(rs.getString("last_failure_instant")),
|
||||
stringToInstant(rs.getString("last_success_instant")),
|
||||
stringToInstant(rs.getString("created_at")),
|
||||
stringToInstant(rs.getString("updated_at")),
|
||||
rs.getString("last_target_path"),
|
||||
rs.getString("last_target_file_name"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet eine ResultSet-Zeile auf einen {@link ProcessingAttempt} ab.
|
||||
*
|
||||
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||
* @return der gemappte Versuch; nie {@code null}
|
||||
* @throws SQLException bei JDBC-Lesefehlern
|
||||
*/
|
||||
private ProcessingAttempt mapToProcessingAttempt(ResultSet rs) throws SQLException {
|
||||
String resolvedDateStr = rs.getString("resolved_date");
|
||||
LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;
|
||||
|
||||
String dateSourceStr = rs.getString("date_source");
|
||||
DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;
|
||||
|
||||
int processedPageCountRaw = rs.getInt("processed_page_count");
|
||||
Integer processedPageCount = rs.wasNull() ? null : processedPageCountRaw;
|
||||
|
||||
int sentCharacterCountRaw = rs.getInt("sent_character_count");
|
||||
Integer sentCharacterCount = rs.wasNull() ? null : sentCharacterCountRaw;
|
||||
|
||||
return new ProcessingAttempt(
|
||||
new DocumentFingerprint(rs.getString("fingerprint")),
|
||||
new RunId(rs.getString("run_id")),
|
||||
rs.getInt("attempt_number"),
|
||||
stringToInstant(rs.getString("started_at")),
|
||||
stringToInstant(rs.getString("ended_at")),
|
||||
ProcessingStatus.valueOf(rs.getString("status")),
|
||||
rs.getString("failure_class"),
|
||||
rs.getString("failure_message"),
|
||||
rs.getBoolean("retryable"),
|
||||
rs.getString("ai_provider"),
|
||||
rs.getString("model_name"),
|
||||
rs.getString("prompt_identifier"),
|
||||
processedPageCount,
|
||||
sentCharacterCount,
|
||||
rs.getString("ai_raw_response"),
|
||||
rs.getString("ai_reasoning"),
|
||||
resolvedDate,
|
||||
dateSource,
|
||||
rs.getString("validated_title"),
|
||||
rs.getString("final_target_file_name"));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SQL-LIKE Escaping
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Escaped Sonderzeichen {@code %} und {@code _} in einer LIKE-Eingabe mit {@code \}.
|
||||
* <p>
|
||||
* Der Escape-Charakter {@code \} muss in der SQL-Abfrage als
|
||||
* {@code ESCAPE '\'} angegeben werden.
|
||||
*
|
||||
* @param input die rohe Benutzereingabe; darf nicht {@code null} sein
|
||||
* @return der escaped String; nie {@code null}
|
||||
*/
|
||||
private static String escapeSqlLike(String input) {
|
||||
return input
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JDBC-Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Öffnet eine neue Datenbankverbindung zur konfigurierten SQLite-Datei.
|
||||
* <p>
|
||||
* Kann in Unterklassen überschrieben werden, um eine gemeinsam genutzte
|
||||
* Transaktions-Verbindung bereitzustellen.
|
||||
*
|
||||
* @return eine neue Datenbankverbindung
|
||||
* @throws SQLException wenn die Verbindung nicht hergestellt werden kann
|
||||
*/
|
||||
protected Connection getConnection() throws SQLException {
|
||||
return DriverManager.getConnection(jdbcUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst einen Instant aus einer String-Darstellung.
|
||||
* <p>
|
||||
* Unterstützt ISO-8601 (modern) und das Legacy-Format {@code yyyy-MM-dd HH:mm:ss} (UTC).
|
||||
*
|
||||
* @param stringValue die String-Darstellung; kann {@code null} sein
|
||||
* @return das geparste Instant, oder {@code null} wenn die Eingabe leer oder nicht parsbar ist
|
||||
*/
|
||||
private Instant stringToInstant(String stringValue) {
|
||||
if (stringValue == null || stringValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Instant.parse(stringValue);
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
LocalDateTime dateTime = LocalDateTime.parse(stringValue,
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
return dateTime.atZone(ZoneId.of("UTC")).toInstant();
|
||||
} catch (Exception fallback) {
|
||||
logger.warn("Instant konnte nicht geparst werden '{}': {}", stringValue, fallback.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-7
@@ -7,8 +7,11 @@ import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -40,6 +43,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
* application/domain type.
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -75,7 +80,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
*/
|
||||
@Override
|
||||
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
|
||||
|
||||
String sql = """
|
||||
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
|
||||
@@ -156,7 +161,8 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
statement.setString(5, attempt.endedAt().toString());
|
||||
statement.setString(6, attempt.status().name());
|
||||
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());
|
||||
// AI provider identifier and AI traceability fields
|
||||
setNullableString(statement, 10, attempt.aiProvider());
|
||||
@@ -200,7 +206,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
*/
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
@@ -251,7 +257,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
*/
|
||||
@Override
|
||||
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
@@ -308,8 +314,8 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
new DocumentFingerprint(rs.getString("fingerprint")),
|
||||
new RunId(rs.getString("run_id")),
|
||||
rs.getInt("attempt_number"),
|
||||
Instant.parse(rs.getString("started_at")),
|
||||
Instant.parse(rs.getString("ended_at")),
|
||||
stringToInstant(rs.getString("started_at")),
|
||||
stringToInstant(rs.getString("ended_at")),
|
||||
ProcessingStatus.valueOf(rs.getString("status")),
|
||||
rs.getString("failure_class"),
|
||||
rs.getString("failure_message"),
|
||||
@@ -328,6 +334,56 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht, einen Instant aus einer String-Darstellung zu parsen.
|
||||
* Unterstützt sowohl modernes ISO-8601-Format als auch Legacy-Format.
|
||||
*
|
||||
* @param stringValue die String-Darstellung des Datums, kann null sein
|
||||
* @return das geparste Instant, oder null wenn stringValue null oder leer ist
|
||||
*/
|
||||
private Instant stringToInstant(String stringValue) {
|
||||
if (stringValue == null || stringValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Versuch mit ISO-8601 Format (moderner Standard)
|
||||
try {
|
||||
return Instant.parse(stringValue);
|
||||
} catch (Exception e) {
|
||||
// Fallback auf älteres Format "yyyy-MM-dd HH:mm:ss" (als UTC)
|
||||
try {
|
||||
LocalDateTime dateTime = LocalDateTime.parse(stringValue,
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
return dateTime.atZone(ZoneId.of("UTC")).toInstant();
|
||||
} catch (Exception fallbackException) {
|
||||
logger.warn("Fehler beim Parsen der Instant-String '{}' in beiden Formaten (ISO-8601 und Legacy-Format)",
|
||||
stringValue, fallbackException);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -368,7 +424,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
*/
|
||||
@Override
|
||||
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 = ?";
|
||||
|
||||
|
||||
+521
-277
@@ -1,337 +1,581 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
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.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort;
|
||||
|
||||
/**
|
||||
* SQLite implementation of {@link PersistenceSchemaInitializationPort}.
|
||||
* <p>
|
||||
* Creates or verifies the two-level persistence schema in the configured SQLite
|
||||
* database file, and performs a controlled schema evolution from an earlier schema
|
||||
* version to the current one.
|
||||
* Flyway-basierte Implementierung von {@link PersistenceSchemaInitializationPort}.
|
||||
*
|
||||
* <h2>Two-level schema</h2>
|
||||
* <p>The schema consists of exactly two tables:
|
||||
* <ol>
|
||||
* <li><strong>{@code document_record}</strong> — the document master record
|
||||
* (Dokument-Stammsatz). One row per unique SHA-256 fingerprint.</li>
|
||||
* <li><strong>{@code processing_attempt}</strong> — the processing attempt history
|
||||
* (Versuchshistorie). One row per historised processing attempt, referencing
|
||||
* the master record via fingerprint.</li>
|
||||
* </ol>
|
||||
* <p>Erstellt oder verifiziert das Zwei-Ebenen-Persistenzschema in der konfigurierten
|
||||
* SQLite-Datenbank und führt dabei eine differenzierte Startstrategie durch,
|
||||
* die drei Fälle unterscheidet:
|
||||
*
|
||||
* <h2>Schema evolution</h2>
|
||||
* <p>
|
||||
* When upgrading from an earlier schema, this adapter uses idempotent
|
||||
* {@code ALTER TABLE ... ADD COLUMN} statements for both tables. Columns that already
|
||||
* exist are silently skipped, making the evolution safe to run on both fresh and existing
|
||||
* databases. The current evolution adds:
|
||||
* <ul>
|
||||
* <li>AI-traceability columns to {@code processing_attempt}</li>
|
||||
* <li>Target-copy columns ({@code last_target_path}, {@code last_target_file_name}) to
|
||||
* {@code document_record}</li>
|
||||
* <li>Target-copy column ({@code final_target_file_name}) to {@code processing_attempt}</li>
|
||||
* <li>Provider-identifier column ({@code ai_provider}) to {@code processing_attempt};
|
||||
* existing rows receive {@code NULL} as the default, which is the correct value for
|
||||
* attempts recorded before provider tracking was introduced.</li>
|
||||
* </ul>
|
||||
* <h2>Fall 1 – Leere Datenbank</h2>
|
||||
* <p>Keine fachlichen Tabellen und keine Flyway-History-Tabelle vorhanden
|
||||
* (bzw. Datei existiert noch nicht). Flyway führt {@code V1__initial_schema.sql}
|
||||
* vollständig aus und legt das komplette Schema an.
|
||||
*
|
||||
* <h2>Legacy-state migration</h2>
|
||||
* <p>
|
||||
* Documents in an earlier positive intermediate state ({@code SUCCESS} recorded without
|
||||
* a validated naming proposal) are idempotently migrated to {@code READY_FOR_AI} so that
|
||||
* the AI naming pipeline processes them in the next run. Terminal negative states
|
||||
* ({@code FAILED_RETRYABLE}, {@code FAILED_FINAL}, skip states) are left unchanged.
|
||||
* <h2>Fall 2 – Bestehende Datenbank ohne Flyway-History</h2>
|
||||
* <p>Fachliche Tabellen sind vorhanden, aber die Flyway-History-Tabelle fehlt.
|
||||
* Vor der Baseline-Eintralung wird eine vollständige Schema-Prüfung gegen das
|
||||
* V1-Zielschema durchgeführt. Bei konformem Schema wird ein datiertes Backup der
|
||||
* SQLite-Datei erstellt, und Flyway trägt nur eine Baseline ein (Skript wird
|
||||
* <em>nicht</em> ausgeführt). Bei fehlendem Schema-Element bricht der Start mit
|
||||
* einer klaren Fehlermeldung ab.
|
||||
*
|
||||
* <h2>Initialisation timing</h2>
|
||||
* <p>This adapter must be invoked <em>once</em> at program startup, before the batch
|
||||
* document processing loop begins.
|
||||
* <h2>Fall 3 – Folgestart mit Flyway-History</h2>
|
||||
* <p>Flyway-History-Tabelle ist vorhanden. Flyway läuft idempotent und
|
||||
* führt nur noch fehlende Migrationen aus.
|
||||
*
|
||||
* <h2>Architecture boundary</h2>
|
||||
* <p>All JDBC connections, SQL DDL, and SQLite-specific behaviour are strictly confined
|
||||
* to this class. No JDBC or SQLite types appear in the port interface or in any
|
||||
* application/domain type.
|
||||
* <h2>Fremdschlüssel</h2>
|
||||
* <p>Foreign-Key-Durchsetzung wird über {@code SQLiteConfig.enforceForeignKeys(true)}
|
||||
* auf DataSource-Ebene aktiviert, sodass jede neue Verbindung automatisch
|
||||
* {@code PRAGMA foreign_keys = ON} erhält.
|
||||
*
|
||||
* <h2>Architekturgrenze</h2>
|
||||
* <p>Alle JDBC-Verbindungen, SQL-DDL und SQLite-spezifisches Verhalten sind
|
||||
* ausschließlich in dieser Klasse gekapselt. Im Port-Interface und in den
|
||||
* Domain-/Application-Typen erscheinen keine JDBC- oder SQLite-Typen.
|
||||
*/
|
||||
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);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DDL — document_record table
|
||||
// Erwartete Tabellen und Spalten gemäß V1-Zielschema
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* DDL for the document master record table.
|
||||
* <p>
|
||||
* Columns: id (PK), fingerprint (unique), last_known_source_locator,
|
||||
* last_known_source_file_name, overall_status, content_error_count,
|
||||
* transient_error_count, last_failure_instant, last_success_instant,
|
||||
* created_at, updated_at.
|
||||
*/
|
||||
private static final String DDL_CREATE_DOCUMENT_RECORD = """
|
||||
CREATE TABLE IF NOT EXISTS document_record (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
last_known_source_locator TEXT NOT NULL,
|
||||
last_known_source_file_name TEXT NOT NULL,
|
||||
overall_status TEXT NOT NULL,
|
||||
content_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
transient_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_failure_instant TEXT,
|
||||
last_success_instant TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
|
||||
)
|
||||
""";
|
||||
/** Alle erwarteten Spalten der Tabelle {@code document_record}. */
|
||||
private static final Set<String> EXPECTED_COLUMNS_DOCUMENT_RECORD = Set.of(
|
||||
"id", COL_FINGERPRINT, "last_known_source_locator", "last_known_source_file_name",
|
||||
"overall_status", "content_error_count", "transient_error_count",
|
||||
"last_failure_instant", "last_success_instant", "created_at", "updated_at",
|
||||
"last_target_path", "last_target_file_name"
|
||||
);
|
||||
|
||||
/** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */
|
||||
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
|
||||
"id", COL_FINGERPRINT, "run_id", "attempt_number", "started_at", "ended_at",
|
||||
"status", "failure_class", "failure_message", "retryable",
|
||||
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
|
||||
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
|
||||
"validated_title", "final_target_file_name", "ai_provider"
|
||||
);
|
||||
|
||||
/** Erwartete Indizes. */
|
||||
private static final Set<String> EXPECTED_INDEXES = Set.of(
|
||||
"idx_processing_attempt_fingerprint",
|
||||
"idx_processing_attempt_run_id",
|
||||
"idx_document_record_overall_status"
|
||||
);
|
||||
|
||||
/** Name der Flyway-History-Tabelle. */
|
||||
private static final String FLYWAY_HISTORY_TABLE = "flyway_schema_history";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DDL — processing_attempt table (base schema, without AI traceability cols)
|
||||
// Felder
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* DDL for the base processing attempt history table.
|
||||
* <p>
|
||||
* Base columns (present in all schema versions): id, fingerprint, run_id,
|
||||
* attempt_number, started_at, ended_at, status, failure_class, failure_message, retryable.
|
||||
* <p>
|
||||
* AI traceability columns are added separately via {@code ALTER TABLE} to support
|
||||
* idempotent evolution from earlier schemas.
|
||||
*/
|
||||
private static final String DDL_CREATE_PROCESSING_ATTEMPT = """
|
||||
CREATE TABLE IF NOT EXISTS processing_attempt (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
failure_class TEXT,
|
||||
failure_message TEXT,
|
||||
retryable INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT fk_processing_attempt_fingerprint
|
||||
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
|
||||
CONSTRAINT uq_processing_attempt_fingerprint_number
|
||||
UNIQUE (fingerprint, attempt_number)
|
||||
)
|
||||
""";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DDL — indexes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Index on {@code processing_attempt.fingerprint} for fast per-document lookups. */
|
||||
private static final String DDL_IDX_ATTEMPT_FINGERPRINT =
|
||||
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint "
|
||||
+ "ON processing_attempt (fingerprint)";
|
||||
|
||||
/** Index on {@code processing_attempt.run_id} for fast per-run lookups. */
|
||||
private static final String DDL_IDX_ATTEMPT_RUN_ID =
|
||||
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_run_id "
|
||||
+ "ON processing_attempt (run_id)";
|
||||
|
||||
/** Index on {@code document_record.overall_status} for fast status-based filtering. */
|
||||
private static final String DDL_IDX_RECORD_STATUS =
|
||||
"CREATE INDEX IF NOT EXISTS idx_document_record_overall_status "
|
||||
+ "ON document_record (overall_status)";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DDL — columns added to processing_attempt via schema evolution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Columns to add idempotently to {@code processing_attempt}.
|
||||
* Each entry is {@code [column_name, column_type]}.
|
||||
* <p>
|
||||
* {@code ai_provider} is nullable; existing rows receive {@code NULL}, which is the
|
||||
* correct sentinel for attempts recorded before provider tracking was introduced.
|
||||
*/
|
||||
private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = {
|
||||
{"model_name", "TEXT"},
|
||||
{"prompt_identifier", "TEXT"},
|
||||
{"processed_page_count", "INTEGER"},
|
||||
{"sent_character_count", "INTEGER"},
|
||||
{"ai_raw_response", "TEXT"},
|
||||
{"ai_reasoning", "TEXT"},
|
||||
{"resolved_date", "TEXT"},
|
||||
{"date_source", "TEXT"},
|
||||
{"validated_title", "TEXT"},
|
||||
{"final_target_file_name", "TEXT"},
|
||||
{"ai_provider", "TEXT"},
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DDL — columns added to document_record via schema evolution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Columns to add idempotently to {@code document_record}.
|
||||
* Each entry is {@code [column_name, column_type]}.
|
||||
*/
|
||||
private static final String[][] EVOLUTION_RECORD_COLUMNS = {
|
||||
{"last_target_path", "TEXT"},
|
||||
{"last_target_file_name", "TEXT"},
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy-state status migration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Migrates earlier positive intermediate states in {@code document_record} that were
|
||||
* recorded as {@code SUCCESS} without a validated naming proposal to {@code READY_FOR_AI},
|
||||
* so the AI naming pipeline processes them in the next run.
|
||||
* <p>
|
||||
* Only rows with {@code overall_status = 'SUCCESS'} that have no corresponding
|
||||
* {@code processing_attempt} with {@code status = 'PROPOSAL_READY'} are updated.
|
||||
* This migration is idempotent.
|
||||
*/
|
||||
private static final String SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI = """
|
||||
UPDATE document_record
|
||||
SET overall_status = 'READY_FOR_AI',
|
||||
updated_at = datetime('now')
|
||||
WHERE overall_status = 'SUCCESS'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM processing_attempt pa
|
||||
WHERE pa.fingerprint = document_record.fingerprint
|
||||
AND pa.status = 'PROPOSAL_READY'
|
||||
)
|
||||
""";
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
/**
|
||||
* Constructs the adapter with the JDBC URL of the SQLite database file.
|
||||
* Erstellt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
|
||||
*
|
||||
* @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
|
||||
* @throws NullPointerException if {@code jdbcUrl} is null
|
||||
* @throws IllegalArgumentException if {@code jdbcUrl} is blank
|
||||
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
|
||||
* @throws NullPointerException wenn {@code jdbcUrl} {@code null} ist
|
||||
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
|
||||
*/
|
||||
public SqliteSchemaInitializationAdapter(String jdbcUrl) {
|
||||
Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null");
|
||||
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
|
||||
if (jdbcUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("jdbcUrl must not be blank");
|
||||
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
|
||||
}
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or verifies the persistence schema and performs schema evolution and
|
||||
* status migration.
|
||||
* <p>
|
||||
* Execution order:
|
||||
* <ol>
|
||||
* <li>Enable foreign key enforcement.</li>
|
||||
* <li>Create {@code document_record} table (if not exists).</li>
|
||||
* <li>Create {@code processing_attempt} table (if not exists).</li>
|
||||
* <li>Create all indexes (if not exist).</li>
|
||||
* <li>Add AI-traceability and provider-identifier columns to {@code processing_attempt}
|
||||
* (idempotent evolution).</li>
|
||||
* <li>Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* All steps are safe to run on both fresh and existing databases.
|
||||
* Erstellt oder verifiziert das Persistenzschema per Flyway.
|
||||
*
|
||||
* @throws DocumentPersistenceException if any DDL or migration step fails
|
||||
* <p>Erkennt anhand des Datenbankzustands automatisch einen der drei Fälle
|
||||
* (leere DB, bestehende DB ohne Flyway-History, Folgestart mit Flyway-History)
|
||||
* und wählt die passende Flyway-Konfiguration.
|
||||
*
|
||||
* @throws DocumentPersistenceException wenn das Schema nicht erstellt oder verifiziert
|
||||
* werden kann, oder wenn die Schema-Prüfung bei
|
||||
* einer bestehenden Datenbank fehlschlägt
|
||||
*/
|
||||
@Override
|
||||
public void initializeSchema() {
|
||||
logger.info("Initialising SQLite persistence schema at: {}", jdbcUrl);
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl);
|
||||
Statement statement = connection.createStatement()) {
|
||||
logger.info("Schema-Initialisierung gestartet für: {}", jdbcUrl);
|
||||
try {
|
||||
DataSource dataSource = createDataSource();
|
||||
DbState state = determineDbState(dataSource);
|
||||
logger.info("Erkannter Datenbankzustand: {}", state);
|
||||
|
||||
// Enable foreign key enforcement (SQLite disables it by default)
|
||||
statement.execute("PRAGMA foreign_keys = ON");
|
||||
|
||||
// Level 1: document master record
|
||||
statement.execute(DDL_CREATE_DOCUMENT_RECORD);
|
||||
logger.debug("Table 'document_record' created or already present.");
|
||||
|
||||
// Level 2: processing attempt history (base columns only)
|
||||
statement.execute(DDL_CREATE_PROCESSING_ATTEMPT);
|
||||
logger.debug("Table 'processing_attempt' created or already present.");
|
||||
|
||||
// Indexes for efficient per-document, per-run, and per-status access
|
||||
statement.execute(DDL_IDX_ATTEMPT_FINGERPRINT);
|
||||
statement.execute(DDL_IDX_ATTEMPT_RUN_ID);
|
||||
statement.execute(DDL_IDX_RECORD_STATUS);
|
||||
logger.debug("Indexes created or already present.");
|
||||
|
||||
// Schema evolution: add AI-traceability + target-copy columns (idempotent)
|
||||
evolveTableColumns(connection, "processing_attempt", EVOLUTION_ATTEMPT_COLUMNS);
|
||||
evolveTableColumns(connection, "document_record", EVOLUTION_RECORD_COLUMNS);
|
||||
|
||||
// Status migration: earlier positive intermediate state → READY_FOR_AI
|
||||
int migrated = statement.executeUpdate(SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI);
|
||||
if (migrated > 0) {
|
||||
logger.info("Status migration: {} document(s) migrated from legacy SUCCESS state to READY_FOR_AI.",
|
||||
migrated);
|
||||
} else {
|
||||
logger.debug("Status migration: no documents required migration.");
|
||||
switch (state) {
|
||||
case EMPTY -> runFall1NewDb(dataSource);
|
||||
case EXISTING_WITHOUT_FLYWAY -> runFall2BaselineExistingDb(dataSource);
|
||||
case FLYWAY_MANAGED -> runFall3FollowUpStart(dataSource);
|
||||
}
|
||||
|
||||
logger.info("SQLite schema initialisation and migration completed successfully.");
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Failed to initialise SQLite persistence schema at '" + jdbcUrl + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
logger.info("Schema-Initialisierung erfolgreich abgeschlossen.");
|
||||
} catch (DocumentPersistenceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
String msg = "Schema-Initialisierung fehlgeschlagen für '" + jdbcUrl + "': " + e.getMessage();
|
||||
logger.error(msg, e);
|
||||
throw new DocumentPersistenceException(msg, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently adds the given columns to the specified table.
|
||||
* <p>
|
||||
* For each column that does not yet exist, an {@code ALTER TABLE ... ADD COLUMN}
|
||||
* statement is executed. Columns that already exist are silently skipped.
|
||||
* Gibt die JDBC-URL zurück, die dieser Adapter verwendet.
|
||||
*
|
||||
* @param connection an open JDBC connection to the database
|
||||
* @param tableName the name of the table to evolve
|
||||
* @param columns array of {@code [column_name, column_type]} pairs to add
|
||||
* @throws SQLException if a column addition fails for a reason other than duplicate column
|
||||
*/
|
||||
private void evolveTableColumns(Connection connection, String tableName, String[][] columns)
|
||||
throws SQLException {
|
||||
java.util.Set<String> existingColumns = new java.util.HashSet<>();
|
||||
try (ResultSet rs = connection.getMetaData().getColumns(null, null, tableName, null)) {
|
||||
while (rs.next()) {
|
||||
existingColumns.add(rs.getString("COLUMN_NAME").toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
for (String[] col : columns) {
|
||||
String columnName = col[0];
|
||||
String columnType = col[1];
|
||||
if (!existingColumns.contains(columnName.toLowerCase())) {
|
||||
String alterSql = "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnType;
|
||||
try (Statement stmt = connection.createStatement()) {
|
||||
stmt.execute(alterSql);
|
||||
}
|
||||
logger.debug("Schema evolution: added column '{}' to '{}'.", columnName, tableName);
|
||||
} else {
|
||||
logger.debug("Schema evolution: column '{}' in '{}' already present, skipped.",
|
||||
columnName, tableName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
|
||||
*
|
||||
* @return the JDBC URL; never null or blank
|
||||
* @return die JDBC-URL; niemals {@code null} oder leer
|
||||
*/
|
||||
public String getJdbcUrl() {
|
||||
return jdbcUrl;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fallbehandlung
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fall 1: Leere Datenbank – Flyway führt V1__initial_schema.sql vollständig aus.
|
||||
*
|
||||
* @param dataSource die konfigurierte DataSource
|
||||
*/
|
||||
private void runFall1NewDb(DataSource dataSource) {
|
||||
logger.info("Fall 1: Leere Datenbank – Flyway legt vollständiges Schema an.");
|
||||
Flyway flyway = buildFlyway(dataSource, false);
|
||||
flyway.migrate();
|
||||
logger.info("Fall 1: Schema vollständig erstellt.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fall 2: Bestehende Datenbank ohne Flyway-History.
|
||||
*
|
||||
* <p>Führt die vollständige Schema-Prüfcheckliste durch. Bei konformem Schema
|
||||
* wird ein datiertes Backup angelegt und Flyway trägt nur eine Baseline ein.
|
||||
* Bei fehlendem Schema-Element bricht der Start ab.
|
||||
*
|
||||
* @param dataSource die konfigurierte DataSource
|
||||
* @throws DocumentPersistenceException wenn das Schema nicht konform ist oder das Backup schlägt fehl
|
||||
*/
|
||||
private void runFall2BaselineExistingDb(DataSource dataSource) {
|
||||
logger.info("Fall 2: Bestehende Datenbank ohne Flyway-History – Schema-Prüfung läuft.");
|
||||
|
||||
// Vollständige Schema-Prüfung vor Baseline
|
||||
try (Connection conn = dataSource.getConnection()) {
|
||||
verifyExistingSchemaMatches(conn);
|
||||
} catch (SQLException e) {
|
||||
String msg = "Datenbankverbindung für Schema-Prüfung fehlgeschlagen: " + e.getMessage();
|
||||
logger.error(msg, e);
|
||||
throw new DocumentPersistenceException(msg, e);
|
||||
}
|
||||
logger.info("Fall 2: Schema-Prüfung bestanden.");
|
||||
|
||||
// Backup der SQLite-Datei anlegen
|
||||
createDatedBackup();
|
||||
|
||||
// Flyway-Baseline eintragen (V1 wird NICHT ausgeführt)
|
||||
Flyway flyway = buildFlyway(dataSource, true);
|
||||
flyway.migrate();
|
||||
logger.info("Fall 2: Flyway-Baseline erfolgreich eingetragen.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fall 3: Folgestart – Flyway läuft idempotent und führt nur fehlende Migrationen aus.
|
||||
*
|
||||
* @param dataSource die konfigurierte DataSource
|
||||
*/
|
||||
private void runFall3FollowUpStart(DataSource dataSource) {
|
||||
logger.info("Fall 3: Folgestart mit Flyway-History – idempotente Migration.");
|
||||
Flyway flyway = buildFlyway(dataSource, false);
|
||||
flyway.migrate();
|
||||
logger.info("Fall 3: Migration abgeschlossen (idempotent).");
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine standardisiert konfigurierte {@link Flyway}-Instanz.
|
||||
*
|
||||
* <p>Alle drei Fälle nutzen dieselbe Grundkonfiguration:
|
||||
* <ul>
|
||||
* <li>Explizite Migrations-Location {@code classpath:db/migration} – verhindert
|
||||
* unerwünschtes Klasspfad-Scannen des gesamten JARs.</li>
|
||||
* <li>Keine Umgebungsvariablen-Konfiguration – verhindert unbeabsichtigte
|
||||
* Übersteuerung durch Build-System-Variablen.</li>
|
||||
* <li>Kein Verbindungs-Retry ({@code connectRetries=0}) – Fehler schlagen
|
||||
* sofort statt nach mehreren Sekunden Wartezeit fehl.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param dataSource die zu verwendende DataSource
|
||||
* @param baselineOnMigrate ob beim Migrate eine Baseline einzutragen ist (nur Fall 2)
|
||||
* @return eine konfigurierte, betriebsbereite {@link Flyway}-Instanz
|
||||
*/
|
||||
private Flyway buildFlyway(DataSource dataSource, boolean baselineOnMigrate) {
|
||||
var config = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.connectRetries(0)
|
||||
.baselineOnMigrate(baselineOnMigrate);
|
||||
if (baselineOnMigrate) {
|
||||
config = config
|
||||
.baselineVersion("1")
|
||||
.baselineDescription("Bestehende Datenbank baselined");
|
||||
}
|
||||
return config.load();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Datenbankzustand erkennen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Repräsentiert den erkannten Zustand der SQLite-Datenbank beim Start.
|
||||
*/
|
||||
enum DbState {
|
||||
/** Keine fachlichen Tabellen und keine Flyway-History vorhanden. */
|
||||
EMPTY,
|
||||
/** Fachliche Tabellen vorhanden, aber keine Flyway-History-Tabelle. */
|
||||
EXISTING_WITHOUT_FLYWAY,
|
||||
/** Flyway-History-Tabelle vorhanden – Datenbank wird bereits von Flyway verwaltet. */
|
||||
FLYWAY_MANAGED
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den aktuellen Zustand der Datenbank.
|
||||
*
|
||||
* <p>"Leer" bedeutet: keine Tabellen vorhanden – nicht nur Dateigröße 0 Byte.
|
||||
*
|
||||
* @param dataSource die zu prüfende DataSource
|
||||
* @return der erkannte {@link DbState}
|
||||
* @throws DocumentPersistenceException bei Verbindungsfehlern
|
||||
*/
|
||||
private DbState determineDbState(DataSource dataSource) {
|
||||
try (Connection conn = dataSource.getConnection()) {
|
||||
DatabaseMetaData meta = conn.getMetaData();
|
||||
Set<String> tables = readTableNames(meta);
|
||||
|
||||
if (tables.contains(FLYWAY_HISTORY_TABLE)) {
|
||||
return DbState.FLYWAY_MANAGED;
|
||||
}
|
||||
// "Leer" = keine Tabellen vorhanden (unabhängig von Dateigröße)
|
||||
boolean hasFachlicheTabellen = tables.contains(TABLE_DOCUMENT_RECORD)
|
||||
|| tables.contains(TABLE_PROCESSING_ATTEMPT);
|
||||
if (hasFachlicheTabellen) {
|
||||
return DbState.EXISTING_WITHOUT_FLYWAY;
|
||||
}
|
||||
return DbState.EMPTY;
|
||||
} catch (SQLException e) {
|
||||
String msg = "Datenbankzustand konnte nicht ermittelt werden: " + e.getMessage();
|
||||
logger.error(msg, e);
|
||||
throw new DocumentPersistenceException(msg, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Schema-Prüfcheckliste (Fall 2)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vollständige Schema-Prüfung gegen das V1-Zielschema.
|
||||
*
|
||||
* <p>Prüft alle erwarteten Tabellen, Spalten, Constraints und Indizes per
|
||||
* {@link DatabaseMetaData}. Bei fehlendem Element wird der Start sofort mit
|
||||
* einer aussagekräftigen Fehlermeldung abgebrochen – kein stilles Heilen.
|
||||
*
|
||||
* @param conn offene JDBC-Verbindung zur Datenbank
|
||||
* @throws DocumentPersistenceException wenn ein Schema-Element fehlt
|
||||
* @throws SQLException bei technischen Datenbankfehlern
|
||||
*/
|
||||
private void verifyExistingSchemaMatches(Connection conn) throws SQLException {
|
||||
DatabaseMetaData meta = conn.getMetaData();
|
||||
List<String> fehler = new ArrayList<>();
|
||||
|
||||
// Tabellen prüfen
|
||||
Set<String> tabellen = readTableNames(meta);
|
||||
if (!tabellen.contains(TABLE_DOCUMENT_RECORD)) {
|
||||
fehler.add("Tabelle 'document_record' fehlt");
|
||||
}
|
||||
if (!tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
|
||||
fehler.add("Tabelle 'processing_attempt' fehlt");
|
||||
}
|
||||
|
||||
// Spalten prüfen – nur wenn Tabellen vorhanden
|
||||
if (tabellen.contains(TABLE_DOCUMENT_RECORD)) {
|
||||
pruefeSpaltenvollstaendigkeit(meta, TABLE_DOCUMENT_RECORD,
|
||||
EXPECTED_COLUMNS_DOCUMENT_RECORD, fehler);
|
||||
}
|
||||
if (tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
|
||||
pruefeSpaltenvollstaendigkeit(meta, TABLE_PROCESSING_ATTEMPT,
|
||||
EXPECTED_COLUMNS_PROCESSING_ATTEMPT, fehler);
|
||||
}
|
||||
|
||||
// Indizes prüfen
|
||||
if (tabellen.contains(TABLE_DOCUMENT_RECORD) && tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
|
||||
Set<String> vorhandeneIndizes = readIndexNames(meta);
|
||||
for (String erwartetIndex : EXPECTED_INDEXES) {
|
||||
if (!vorhandeneIndizes.contains(erwartetIndex)) {
|
||||
fehler.add("Index '" + erwartetIndex + "' fehlt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Constraints prüfen (soweit per Metadata prüfbar)
|
||||
if (tabellen.contains(TABLE_DOCUMENT_RECORD)) {
|
||||
pruefeUniqueConstraintAufFingerprint(conn, fehler);
|
||||
}
|
||||
if (tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
|
||||
pruefeForeignKeyAufDocumentRecord(conn, fehler);
|
||||
}
|
||||
|
||||
if (!fehler.isEmpty()) {
|
||||
String fehlerliste = String.join("; ", fehler);
|
||||
String msg = "Schema-Prüfung fehlgeschlagen – folgende Elemente fehlen oder sind nicht konform: "
|
||||
+ fehlerliste;
|
||||
logger.error(msg);
|
||||
throw new DocumentPersistenceException(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob alle erwarteten Spalten in der angegebenen Tabelle vorhanden sind.
|
||||
*
|
||||
* @param meta Datenbankmetadaten
|
||||
* @param tabellenname Name der zu prüfenden Tabelle
|
||||
* @param erwarteteSpalten Menge der erwarteten Spaltennamen (Kleinschreibung)
|
||||
* @param fehler Liste, in die fehlende Elemente eingetragen werden
|
||||
* @throws SQLException bei technischen Datenbankfehlern
|
||||
*/
|
||||
private void pruefeSpaltenvollstaendigkeit(DatabaseMetaData meta, String tabellenname,
|
||||
Set<String> erwarteteSpalten, List<String> fehler) throws SQLException {
|
||||
Set<String> vorhandeneSpalten = new HashSet<>();
|
||||
try (ResultSet rs = meta.getColumns(null, null, tabellenname, null)) {
|
||||
while (rs.next()) {
|
||||
vorhandeneSpalten.add(rs.getString("COLUMN_NAME").toLowerCase());
|
||||
}
|
||||
}
|
||||
for (String erwartet : erwarteteSpalten) {
|
||||
if (!vorhandeneSpalten.contains(erwartet)) {
|
||||
fehler.add("Spalte '" + tabellenname + "." + erwartet + "' fehlt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft das UNIQUE-Constraint auf {@code document_record.fingerprint} anhand der
|
||||
* Indexmetadaten.
|
||||
*
|
||||
* @param conn offene JDBC-Verbindung
|
||||
* @param fehler Liste, in die fehlende Elemente eingetragen werden
|
||||
* @throws SQLException bei technischen Datenbankfehlern
|
||||
*/
|
||||
private void pruefeUniqueConstraintAufFingerprint(Connection conn,
|
||||
List<String> fehler) throws SQLException {
|
||||
boolean uniqueGefunden = false;
|
||||
try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, TABLE_DOCUMENT_RECORD, true, false)) {
|
||||
while (rs.next()) {
|
||||
String spalte = rs.getString("COLUMN_NAME");
|
||||
if (COL_FINGERPRINT.equalsIgnoreCase(spalte)) {
|
||||
uniqueGefunden = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uniqueGefunden) {
|
||||
fehler.add("UNIQUE-Constraint auf 'document_record.fingerprint' fehlt");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft den Foreign Key von {@code processing_attempt.fingerprint} auf
|
||||
* {@code document_record.fingerprint} anhand der Importschlüssel-Metadaten.
|
||||
*
|
||||
* @param conn offene JDBC-Verbindung
|
||||
* @param fehler Liste, in die fehlende Elemente eingetragen werden
|
||||
* @throws SQLException bei technischen Datenbankfehlern
|
||||
*/
|
||||
private void pruefeForeignKeyAufDocumentRecord(Connection conn,
|
||||
List<String> fehler) throws SQLException {
|
||||
boolean fkGefunden = false;
|
||||
try (ResultSet rs = conn.getMetaData().getImportedKeys(null, null, TABLE_PROCESSING_ATTEMPT)) {
|
||||
while (rs.next()) {
|
||||
String pkTabelle = rs.getString("PKTABLE_NAME");
|
||||
String fkSpalte = rs.getString("FKCOLUMN_NAME");
|
||||
if (TABLE_DOCUMENT_RECORD.equalsIgnoreCase(pkTabelle)
|
||||
&& COL_FINGERPRINT.equalsIgnoreCase(fkSpalte)) {
|
||||
fkGefunden = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fkGefunden) {
|
||||
fehler.add("Foreign Key von 'processing_attempt.fingerprint' auf 'document_record.fingerprint' fehlt");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Backup-Erstellung (Fall 2)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Erstellt eine datierte Kopie der SQLite-Datei als Backup.
|
||||
*
|
||||
* <p>Das Backup-Dateiname-Schema lautet: {@code <original>.<timestamp>.bak},
|
||||
* z. B. {@code data.db.20260430T120000Z.bak}.
|
||||
* Bei einer Kollision wird ein Zähler angehängt.
|
||||
*
|
||||
* @throws DocumentPersistenceException wenn das Backup nicht angelegt werden kann
|
||||
*/
|
||||
private void createDatedBackup() {
|
||||
Path dbPath = extractDbPath();
|
||||
if (dbPath == null) {
|
||||
logger.warn("Kein lokaler Dateipfad aus JDBC-URL ableitbar – Backup übersprungen: {}", jdbcUrl);
|
||||
return;
|
||||
}
|
||||
if (!Files.exists(dbPath)) {
|
||||
logger.debug("Datenbankdatei existiert noch nicht – kein Backup nötig.");
|
||||
return;
|
||||
}
|
||||
|
||||
String zeitstempel = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
||||
.format(java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC));
|
||||
Path backup = dbPath.resolveSibling(dbPath.getFileName() + "." + zeitstempel + ".bak");
|
||||
|
||||
// Kollisionsauflösung
|
||||
int zaehler = 1;
|
||||
while (Files.exists(backup)) {
|
||||
backup = dbPath.resolveSibling(dbPath.getFileName() + "." + zeitstempel + "." + zaehler + ".bak");
|
||||
zaehler++;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.copy(dbPath, backup, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
logger.info("Backup der Datenbankdatei erstellt: {}", backup);
|
||||
} catch (IOException e) {
|
||||
String msg = "Backup der Datenbankdatei konnte nicht erstellt werden: " + e.getMessage();
|
||||
logger.error(msg, e);
|
||||
throw new DocumentPersistenceException(msg, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet den Dateisystempfad aus der JDBC-URL ab.
|
||||
*
|
||||
* <p>Erwartet URLs der Form {@code jdbc:sqlite:/pfad/zur/datei.db}.
|
||||
*
|
||||
* @return der abgeleitete {@link Path} oder {@code null}, wenn kein Pfad ableitbar ist
|
||||
*/
|
||||
private Path extractDbPath() {
|
||||
// Erwartet: jdbc:sqlite:/pfad/zur/datei oder jdbc:sqlite:C:/pfad/datei
|
||||
String prefix = "jdbc:sqlite:";
|
||||
if (!jdbcUrl.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
String pfad = jdbcUrl.substring(prefix.length());
|
||||
if (pfad.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Paths.get(pfad);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Pfad aus JDBC-URL konnte nicht geparst werden: {}", pfad);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DataSource-Erstellung
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Erstellt eine {@link SQLiteDataSource} mit aktivierten Fremdschlüsseln.
|
||||
*
|
||||
* <p>Die Aktivierung über {@link SQLiteConfig#enforceForeignKeys(boolean)} stellt
|
||||
* sicher, dass jede neue Verbindung automatisch {@code PRAGMA foreign_keys = ON}
|
||||
* erhält – ein einmaliges Statement nach dem Verbindungsaufbau wäre nicht ausreichend.
|
||||
*
|
||||
* @return eine konfigurierte {@link DataSource}; niemals {@code null}
|
||||
*/
|
||||
private DataSource createDataSource() {
|
||||
SQLiteConfig config = new SQLiteConfig();
|
||||
config.enforceForeignKeys(true);
|
||||
SQLiteDataSource ds = new SQLiteDataSource(config);
|
||||
ds.setUrl(jdbcUrl);
|
||||
return ds;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Liest alle Tabellennamen aus den Datenbankmetadaten (Kleinschreibung).
|
||||
*
|
||||
* @param meta Datenbankmetadaten
|
||||
* @return Menge aller Tabellennamen in Kleinschreibung
|
||||
* @throws SQLException bei technischen Datenbankfehlern
|
||||
*/
|
||||
private static Set<String> readTableNames(DatabaseMetaData meta) throws SQLException {
|
||||
Set<String> names = new HashSet<>();
|
||||
try (ResultSet rs = meta.getTables(null, null, "%", new String[]{"TABLE"})) {
|
||||
while (rs.next()) {
|
||||
names.add(rs.getString("TABLE_NAME").toLowerCase());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest alle Indexnamen aus den Datenbankmetadaten für beide fachlichen Tabellen.
|
||||
*
|
||||
* @param meta Datenbankmetadaten
|
||||
* @return Menge aller Indexnamen in Kleinschreibung
|
||||
* @throws SQLException bei technischen Datenbankfehlern
|
||||
*/
|
||||
private static Set<String> readIndexNames(DatabaseMetaData meta) throws SQLException {
|
||||
Set<String> names = new HashSet<>();
|
||||
for (String tabelle : new String[]{TABLE_DOCUMENT_RECORD, TABLE_PROCESSING_ATTEMPT}) {
|
||||
try (ResultSet rs = meta.getIndexInfo(null, null, tabelle, false, false)) {
|
||||
while (rs.next()) {
|
||||
String indexName = rs.getString("INDEX_NAME");
|
||||
if (indexName != null) {
|
||||
names.add(indexName.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
+64
-29
@@ -24,6 +24,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
* and processing attempt repositories.
|
||||
*/
|
||||
public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||
private static final String ROLLBACK_FAILED_MSG = "Rollback fehlgeschlagen: {}";
|
||||
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(SqliteUnitOfWorkAdapter.class);
|
||||
|
||||
@@ -41,58 +43,51 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||
public void executeInTransaction(Consumer<TransactionOperations> operations) {
|
||||
Objects.requireNonNull(operations, "operations must not be null");
|
||||
|
||||
Connection connection = null;
|
||||
try {
|
||||
connection = DriverManager.getConnection(jdbcUrl);
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
|
||||
connection.setAutoCommit(false);
|
||||
|
||||
try {
|
||||
TransactionOperationsImpl txOps = new TransactionOperationsImpl(connection);
|
||||
operations.accept(txOps);
|
||||
|
||||
connection.commit();
|
||||
logger.debug("Transaction committed successfully");
|
||||
logger.debug("Transaktion erfolgreich abgeschlossen.");
|
||||
|
||||
} catch (DocumentPersistenceException e) {
|
||||
// Re-throw document-level persistence errors as-is, but still rollback
|
||||
if (connection != null) {
|
||||
// Datenbankfehler auf Dokumentebene: Rollback, dann weiterpropagieren
|
||||
try {
|
||||
connection.rollback();
|
||||
logger.debug("Transaction rolled back due to document error: {}", e.getMessage());
|
||||
logger.debug("Transaktion zurückgerollt (Dokumentfehler): {}", e.getMessage());
|
||||
} catch (SQLException rollbackEx) {
|
||||
logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx);
|
||||
}
|
||||
logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
|
||||
}
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
// Rollback on any RuntimeException and wrap in DocumentPersistenceException
|
||||
if (connection != null) {
|
||||
// Unerwarteter Laufzeitfehler: Rollback, dann als Persistenzfehler weitergeben
|
||||
try {
|
||||
connection.rollback();
|
||||
logger.debug("Transaction rolled back due to error: {}", e.getMessage());
|
||||
logger.debug("Transaktion zurückgerollt (Laufzeitfehler): {}", e.getMessage());
|
||||
} catch (SQLException rollbackEx) {
|
||||
logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx);
|
||||
logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
|
||||
}
|
||||
}
|
||||
throw new DocumentPersistenceException("Transaction failed: " + e.getMessage(), e);
|
||||
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
|
||||
} catch (SQLException e) {
|
||||
// Rollback for any SQL error
|
||||
if (connection != null) {
|
||||
// SQL-Fehler innerhalb der Transaktion: Rollback, dann als Persistenzfehler weitergeben
|
||||
try {
|
||||
connection.rollback();
|
||||
logger.debug("Transaction rolled back due to error: {}", e.getMessage());
|
||||
logger.debug("Transaktion zurückgerollt (SQL-Fehler): {}", e.getMessage());
|
||||
} catch (SQLException rollbackEx) {
|
||||
logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx);
|
||||
logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
|
||||
}
|
||||
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
|
||||
}
|
||||
throw new DocumentPersistenceException("Transaction failed: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
try {
|
||||
connection.close();
|
||||
|
||||
} catch (DocumentPersistenceException e) {
|
||||
throw e;
|
||||
} catch (SQLException e) {
|
||||
logger.warn("Failed to close connection: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
// Verbindungsaufbau oder setAutoCommit(false) fehlgeschlagen
|
||||
throw new DocumentPersistenceException(
|
||||
"Datenbankverbindung konnte nicht hergestellt werden: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +173,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||
*/
|
||||
@Override
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// Delete attempts first (FK constraint: processing_attempt → document_record)
|
||||
// Zuerst Versuche löschen (FK-Constraint: processing_attempt → document_record)
|
||||
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
|
||||
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
|
||||
@Override
|
||||
@@ -188,7 +183,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||
};
|
||||
attemptRepo.deleteAllByFingerprint(fingerprint);
|
||||
|
||||
// Then delete the master record
|
||||
// Dann den Stammsatz löschen
|
||||
SqliteDocumentRecordRepositoryAdapter recordRepo =
|
||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
|
||||
@Override
|
||||
@@ -198,5 +193,45 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||
};
|
||||
recordRepo.deleteByFingerprint(fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
|
||||
* ohne die Versuchshistorie zu löschen.
|
||||
* <p>
|
||||
* Die Felder {@code overall_status}, {@code content_error_count},
|
||||
* {@code transient_error_count} und {@code last_failure_instant} werden innerhalb
|
||||
* der laufenden Transaktion per direktem SQL-UPDATE aktualisiert.
|
||||
* Alle anderen Felder sowie alle {@code processing_attempt}-Einträge bleiben unverändert.
|
||||
* <p>
|
||||
* Ist kein Stammsatz für den Fingerprint vorhanden, kehrt die Methode stillschweigend zurück.
|
||||
*
|
||||
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
|
||||
* darf nicht {@code null} sein
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
*/
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
|
||||
String sql = """
|
||||
UPDATE document_record SET
|
||||
overall_status = 'READY_FOR_AI',
|
||||
content_error_count = 0,
|
||||
transient_error_count = 0,
|
||||
last_failure_instant = NULL
|
||||
WHERE fingerprint = ?
|
||||
""";
|
||||
|
||||
try (java.sql.PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||
stmt.setString(1, fingerprint.sha256Hex());
|
||||
stmt.executeUpdate();
|
||||
logger.debug("Status-Reset (feldgenau) für Fingerprint: {}", fingerprint.sha256Hex());
|
||||
} catch (java.sql.SQLException e) {
|
||||
String message = "Status-Reset fehlgeschlagen für Fingerprint '"
|
||||
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-24
@@ -1,35 +1,43 @@
|
||||
/**
|
||||
* SQLite persistence adapter for the two-level persistence model.
|
||||
* SQLite-Persistenz-Adapter für das Zwei-Ebenen-Persistenzmodell.
|
||||
*
|
||||
* <h2>Purpose</h2>
|
||||
* <p>This package contains the technical SQLite infrastructure for the persistence
|
||||
* layer. It is the only place in the entire application where JDBC connections, SQL DDL,
|
||||
* and SQLite-specific types are used. No JDBC or SQLite types leak into the
|
||||
* {@code application} or {@code domain} modules.
|
||||
* <h2>Zweck</h2>
|
||||
* <p>Dieses Paket enthält die technische SQLite-Infrastruktur der Persistenzschicht.
|
||||
* Es ist die einzige Stelle in der gesamten Anwendung, an der JDBC-Verbindungen,
|
||||
* SQL-DDL und SQLite-spezifische Typen verwendet werden. Keine JDBC- oder
|
||||
* SQLite-Typen verlassen dieses Paket in Richtung der {@code application}-
|
||||
* oder {@code domain}-Module.
|
||||
*
|
||||
* <h2>Two-level persistence model</h2>
|
||||
* <p>Persistence is structured in exactly two levels:
|
||||
* <h2>Zwei-Ebenen-Persistenzmodell</h2>
|
||||
* <p>Die Persistenz ist in genau zwei Ebenen strukturiert:
|
||||
* <ol>
|
||||
* <li><strong>Document master record</strong> ({@code document_record} table) —
|
||||
* one row per unique SHA-256 fingerprint; carries the current overall status,
|
||||
* failure counters, and the most recently known source location.</li>
|
||||
* <li><strong>Processing attempt history</strong> ({@code processing_attempt} table) —
|
||||
* one row per historised processing attempt; references the master record via
|
||||
* fingerprint; attempt numbers are monotonically increasing per fingerprint.</li>
|
||||
* <li><strong>Dokument-Stammsatz</strong> ({@code document_record}-Tabelle) –
|
||||
* eine Zeile pro eindeutigem SHA-256-Fingerprint; trägt den aktuellen
|
||||
* Gesamtstatus, Fehlerzähler und den zuletzt bekannten Quellort.</li>
|
||||
* <li><strong>Versuchshistorie</strong> ({@code processing_attempt}-Tabelle) –
|
||||
* eine Zeile pro historisiertem Verarbeitungsversuch; referenziert den
|
||||
* Stammsatz über den Fingerprint; Versuchsnummern sind pro Fingerprint
|
||||
* monoton steigend.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h2>Schema initialisation timing</h2>
|
||||
* <p>The {@link de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter}
|
||||
* implements the
|
||||
* <h2>Schema-Initialisierung mit Flyway</h2>
|
||||
* <p>Der {@link de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter}
|
||||
* implementiert den
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort}
|
||||
* and must be called <em>once</em> at program startup, before the batch document
|
||||
* processing loop begins. There is no lazy or hidden initialisation during document
|
||||
* processing.
|
||||
* und muss <em>einmal</em> beim Programmstart aufgerufen werden, bevor die
|
||||
* Verarbeitungsschleife beginnt. Die Initialisierung unterscheidet drei Fälle:
|
||||
* leere Datenbank, bestehende Datenbank ohne Flyway-History (Baseline-Eintragung
|
||||
* nach vollständiger Schema-Prüfung) und Folgestart mit Flyway-History (idempotent).
|
||||
*
|
||||
* <h2>Architecture boundary</h2>
|
||||
* <p>All JDBC connections, SQL statements, and SQLite-specific behaviour are strictly
|
||||
* confined to this package. The application layer interacts exclusively through the
|
||||
* port interfaces defined in
|
||||
* <h2>Fremdschlüssel</h2>
|
||||
* <p>Foreign-Key-Durchsetzung wird über {@code SQLiteConfig.enforceForeignKeys(true)}
|
||||
* auf DataSource-Ebene aktiviert, sodass jede neue Verbindung automatisch
|
||||
* {@code PRAGMA foreign_keys = ON} erhält.
|
||||
*
|
||||
* <h2>Architekturgrenze</h2>
|
||||
* <p>Alle JDBC-Verbindungen, SQL-Anweisungen und SQLite-spezifisches Verhalten sind
|
||||
* ausschließlich in diesem Paket gekapselt. Die Application-Schicht interagiert
|
||||
* ausschließlich über die Port-Interfaces in
|
||||
* {@code de.gecheckt.pdf.umbenenner.application.port.out}.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Vollständiges Basisschema: Dokument-Stammsatz und Versuchshistorie.
|
||||
-- Dieses Skript wird für neue Datenbanken ausgeführt (Fall 1).
|
||||
-- Für bestehende Datenbanken mit konformem Schema wird nur eine Flyway-Baseline
|
||||
-- eingetragen; das Skript wird in diesem Fall NICHT ausgeführt (Fall 2).
|
||||
|
||||
CREATE TABLE document_record (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
last_known_source_locator TEXT NOT NULL,
|
||||
last_known_source_file_name TEXT NOT NULL,
|
||||
overall_status TEXT NOT NULL,
|
||||
content_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
transient_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_failure_instant TEXT,
|
||||
last_success_instant TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_target_path TEXT,
|
||||
last_target_file_name TEXT,
|
||||
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
|
||||
);
|
||||
|
||||
CREATE TABLE processing_attempt (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
failure_class TEXT,
|
||||
failure_message TEXT,
|
||||
retryable INTEGER NOT NULL DEFAULT 0,
|
||||
model_name TEXT,
|
||||
prompt_identifier TEXT,
|
||||
processed_page_count INTEGER,
|
||||
sent_character_count INTEGER,
|
||||
ai_raw_response TEXT,
|
||||
ai_reasoning TEXT,
|
||||
resolved_date TEXT,
|
||||
date_source TEXT,
|
||||
validated_title TEXT,
|
||||
final_target_file_name TEXT,
|
||||
ai_provider TEXT,
|
||||
CONSTRAINT fk_processing_attempt_fingerprint
|
||||
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
|
||||
CONSTRAINT uq_processing_attempt_fingerprint_number
|
||||
UNIQUE (fingerprint, attempt_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_processing_attempt_fingerprint
|
||||
ON processing_attempt (fingerprint);
|
||||
|
||||
CREATE INDEX idx_processing_attempt_run_id
|
||||
ON processing_attempt (run_id);
|
||||
|
||||
CREATE INDEX idx_document_record_overall_status
|
||||
ON document_record (overall_status);
|
||||
+15
-5
@@ -214,10 +214,20 @@ class AnthropicClaudeAdapterIntegrationTest {
|
||||
* where log output is not relevant to the assertion.
|
||||
*/
|
||||
private static class NoOpProcessingLogger implements ProcessingLogger {
|
||||
@Override public void info(String message, Object... args) {}
|
||||
@Override public void debug(String message, Object... args) {}
|
||||
@Override public void warn(String message, Object... args) {}
|
||||
@Override public void error(String message, Object... args) {}
|
||||
@Override public void debugSensitiveAiContent(String message, Object... args) {}
|
||||
@Override public void info(String message, Object... args) {
|
||||
// intentionally empty
|
||||
}
|
||||
@Override public void debug(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+132
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link FilesystemPromptPortAdapter}.
|
||||
@@ -199,4 +200,135 @@ class FilesystemPromptPortAdapterTest {
|
||||
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
|
||||
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// savePrompt tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldReturnSaved_whenTargetDirExistsAndWriteSucceeds() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_save.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String content = "Mein Prompt-Inhalt";
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt(content);
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
PromptSaveResult.Saved saved = (PromptSaveResult.Saved) result;
|
||||
assertThat(saved.absolutePath()).contains("prompt_save.txt");
|
||||
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldPreserveUtf8Content_includingUmlauts() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_umlaut.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String content = "Ärger mit Überschriften und Schluß";
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt(content);
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldPreserveLineEndings_withoutNormalization() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_lineendings.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String content = "Zeile 1\r\nZeile 2\nZeile 3\r\n";
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt(content);
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
byte[] raw = Files.readAllBytes(promptFile);
|
||||
assertThat(new String(raw, StandardCharsets.UTF_8)).isEqualTo(content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldOverwriteExistingFile_atomically() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_overwrite.txt");
|
||||
Files.writeString(promptFile, "Alter Inhalt", StandardCharsets.UTF_8);
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String newContent = "Neuer Inhalt";
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt(newContent);
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(newContent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldReturnTargetDirectoryMissing_whenDirectoryDoesNotExist() {
|
||||
// Given
|
||||
Path nonExistentDir = tempDir.resolve("missing-subdir");
|
||||
Path promptFile = nonExistentDir.resolve("prompt.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt("Inhalt");
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
|
||||
PromptSaveResult.TargetDirectoryMissing missing = (PromptSaveResult.TargetDirectoryMissing) result;
|
||||
assertThat(missing.message()).contains("missing-subdir");
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldThrowNullPointerException_whenContentIsNull() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_null.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
|
||||
// When & Then
|
||||
assertThatThrownBy(() -> adapter.savePrompt(null))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessage("content must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldLeaveDirClean_whenTargetDirectoryIsMissing() {
|
||||
// Given – Verzeichnis existiert nicht; keine Temp-Datei soll zurückbleiben
|
||||
Path nonExistentDir = tempDir.resolve("ghost-dir");
|
||||
Path promptFile = nonExistentDir.resolve("prompt.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt("Inhalt");
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
|
||||
// Verzeichnis wurde nicht angelegt (da Directory-Check fehlschlug)
|
||||
assertThat(nonExistentDir).doesNotExist();
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_roundTrip_loadAfterSaveReturnsSameContent() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_roundtrip.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String content = "Runde-Trip-Inhalt\nMit mehreren Zeilen.";
|
||||
|
||||
// When
|
||||
PromptSaveResult saveResult = adapter.savePrompt(content);
|
||||
PromptLoadingResult loadResult = adapter.loadPrompt();
|
||||
|
||||
// Then
|
||||
assertThat(saveResult).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
assertThat(loadResult).isInstanceOf(PromptLoadingSuccess.class);
|
||||
PromptLoadingSuccess success = (PromptLoadingSuccess) loadResult;
|
||||
// loadPrompt trims the content; trim the expected too
|
||||
assertThat(success.promptContent()).isEqualTo(content.trim());
|
||||
}
|
||||
}
|
||||
|
||||
+45
-48
@@ -24,11 +24,11 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
|
||||
/**
|
||||
* Tests for the additive {@code ai_provider} column in {@code processing_attempt}.
|
||||
* <p>
|
||||
* Covers schema migration (idempotency, nullable default for existing rows),
|
||||
* write/read round-trips for both supported provider identifiers, and
|
||||
* backward compatibility with databases created before provider tracking was introduced.
|
||||
* Tests für {@code ai_provider} in {@code processing_attempt}.
|
||||
*
|
||||
* <p>Prüft Schreib-/Lese-Roundtrips für beide Provider-Identifikatoren,
|
||||
* Idempotenz der Initialisierung sowie das Verhalten bei Schemata,
|
||||
* die nicht dem Zielschema entsprechen (harter Abbruch per Fall-2-Strategie).
|
||||
*/
|
||||
class SqliteAttemptProviderPersistenceTest {
|
||||
|
||||
@@ -64,25 +64,24 @@ class SqliteAttemptProviderPersistenceTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A database that already has the {@code processing_attempt} table without
|
||||
* {@code ai_provider} (simulating an existing installation before this column was added)
|
||||
* must receive the column via the idempotent schema evolution.
|
||||
* Eine bestehende Datenbank ohne {@code ai_provider}-Spalte in {@code processing_attempt}
|
||||
* entspricht nicht dem vollständigen Zielschema. Die Initialisierung muss mit einem
|
||||
* klaren Fehler abbrechen, da kein stilles Heilen stattfindet.
|
||||
*/
|
||||
@Test
|
||||
void addsProviderColumnOnExistingDbWithoutColumn() throws SQLException {
|
||||
// Bootstrap schema without the ai_provider column (simulate legacy DB)
|
||||
void existingDbOhneAiProviderSpalte_brichtAb() throws SQLException {
|
||||
// Schema ohne ai_provider anlegen
|
||||
createLegacySchema();
|
||||
|
||||
assertThat(columnExists("processing_attempt", "ai_provider"))
|
||||
.as("ai_provider must not be present before evolution")
|
||||
.as("ai_provider darf im Legacy-Schema noch nicht vorhanden sein")
|
||||
.isFalse();
|
||||
|
||||
// Running initializeSchema must add the column
|
||||
schemaAdapter.initializeSchema();
|
||||
|
||||
assertThat(columnExists("processing_attempt", "ai_provider"))
|
||||
.as("ai_provider column must be added by schema evolution")
|
||||
.isTrue();
|
||||
// Initialisierung muss mit Fehler abbrechen (nicht konformes Schema)
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
|
||||
() -> schemaAdapter.initializeSchema(),
|
||||
"Erwarte Fehler bei nicht konformem Schema (fehlende ai_provider-Spalte)");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,25 +100,28 @@ class SqliteAttemptProviderPersistenceTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows that existed before the {@code ai_provider} column was added must have
|
||||
* {@code NULL} as the column value, not a non-null default.
|
||||
* Neue Versuche die ohne Provider-Information gespeichert werden (z. B. über
|
||||
* {@code ProcessingAttempt.withoutAiFields}), müssen {@code null} als
|
||||
* {@code ai_provider} zurückliefern.
|
||||
*/
|
||||
@Test
|
||||
void existingRowsKeepNullProvider() throws SQLException {
|
||||
// Create legacy schema and insert a row without ai_provider
|
||||
createLegacySchema();
|
||||
DocumentFingerprint fp = fingerprint("aa");
|
||||
insertLegacyDocumentRecord(fp);
|
||||
insertLegacyAttemptRow(fp, "READY_FOR_AI");
|
||||
|
||||
// Now evolve the schema
|
||||
void neuerVersuchOhneProvider_haeltNullProviderNachSchreibenUndLesen() {
|
||||
schemaAdapter.initializeSchema();
|
||||
DocumentFingerprint fp = fingerprint("aa");
|
||||
insertDocumentRecord(fp);
|
||||
|
||||
// Read the existing row — ai_provider must be NULL
|
||||
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
|
||||
assertThat(attempts).hasSize(1);
|
||||
assertThat(attempts.get(0).aiProvider())
|
||||
.as("Existing rows must have NULL ai_provider after schema evolution")
|
||||
java.time.Instant now = java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS);
|
||||
ProcessingAttempt attemptOhneProvider = ProcessingAttempt.withoutAiFields(
|
||||
fp, new RunId("run-null"), 1,
|
||||
now, now.plusSeconds(1),
|
||||
ProcessingStatus.FAILED_RETRYABLE,
|
||||
"Err", "msg", true);
|
||||
repository.save(attemptOhneProvider);
|
||||
|
||||
List<ProcessingAttempt> gelesen = repository.findAllByFingerprint(fp);
|
||||
assertThat(gelesen).hasSize(1);
|
||||
assertThat(gelesen.get(0).aiProvider())
|
||||
.as("Versuche ohne Provider müssen null zurückgeben")
|
||||
.isNull();
|
||||
}
|
||||
|
||||
@@ -213,29 +215,24 @@ class SqliteAttemptProviderPersistenceTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading a database that was created without the {@code ai_provider} column
|
||||
* (a pre-extension database) must succeed; the new field must be empty/null
|
||||
* for historical attempts.
|
||||
* Eine Datenbank mit nicht konformem Schema (fehlende Spalten, fehlende Indizes)
|
||||
* wird von der Initialisierung mit einem klaren Fehler abgebrochen.
|
||||
* Es findet kein stilles Heilen statt.
|
||||
*/
|
||||
@Test
|
||||
void legacyDataReadingDoesNotFail() throws SQLException {
|
||||
// Set up legacy schema with a row that has no ai_provider column
|
||||
void nichtKonformesSchema_brichtMitAussagekraeftigemFehlerAb() throws SQLException {
|
||||
// Legacy-Schema anlegen (fehlt: ai_provider, last_target_path, last_target_file_name,
|
||||
// Indizes fehlen ebenfalls)
|
||||
createLegacySchema();
|
||||
DocumentFingerprint fp = fingerprint("ee");
|
||||
insertLegacyDocumentRecord(fp);
|
||||
insertLegacyAttemptRow(fp, "FAILED_RETRYABLE");
|
||||
|
||||
// Evolve schema — now ai_provider column exists but legacy rows have NULL
|
||||
schemaAdapter.initializeSchema();
|
||||
|
||||
// Reading must not throw and must return null for ai_provider
|
||||
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
|
||||
assertThat(attempts).hasSize(1);
|
||||
assertThat(attempts.get(0).aiProvider())
|
||||
.as("Legacy attempt (from before provider tracking) must have null aiProvider")
|
||||
.isNull();
|
||||
// Other fields must still be readable
|
||||
assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
||||
// Initialisierung muss abbrechen
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
|
||||
() -> schemaAdapter.initializeSchema(),
|
||||
"Erwarte Fehler bei nicht konformem Bestands-Schema");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
+360
-267
@@ -1,8 +1,10 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
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.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
@@ -14,38 +16,31 @@ import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
|
||||
/**
|
||||
* Tests for {@link SqliteSchemaInitializationAdapter}.
|
||||
* <p>
|
||||
* Verifies that the two-level schema is created correctly, that schema evolution
|
||||
* (idempotent addition of AI traceability columns) works, that the idempotent
|
||||
* status migration of earlier positive intermediate states to {@code READY_FOR_AI}
|
||||
* is correct, and that invalid configuration is rejected.
|
||||
* Tests für {@link SqliteSchemaInitializationAdapter}.
|
||||
*
|
||||
* <p>Prüft die differenzierte 3-Fall-Strategie (leere DB, bestehende DB ohne
|
||||
* Flyway-History, Folgestart), die vollständige Schema-Prüfcheckliste für Fall 2,
|
||||
* die Foreign-Key-Aktivierung via DataSource sowie den Konstruktor.
|
||||
*/
|
||||
class SqliteSchemaInitializationAdapterTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Construction
|
||||
// Konstruktor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullJdbcUrl() {
|
||||
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(null))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessageContaining("jdbcUrl");
|
||||
.isInstanceOf(NullPointerException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_rejectsBlankJdbcUrl() {
|
||||
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(" "))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("jdbcUrl");
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -56,213 +51,283 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Schema creation – tables present
|
||||
// Fall 1: Leere Datenbank – vollständiges Schema anlegen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void initializeSchema_createsBothTables(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "schema_test.db");
|
||||
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||
void fall1_leereDb_laegtVollstaendigesSchemaAn(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "fall1.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
adapter.initializeSchema();
|
||||
|
||||
Set<String> tables = readTableNames(jdbcUrl);
|
||||
assertThat(tables).contains("document_record", "processing_attempt");
|
||||
Set<String> tabellen = readTableNames(jdbcUrl);
|
||||
assertThat(tabellen).contains("document_record", "processing_attempt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializeSchema_documentRecordHasAllMandatoryColumns(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "columns_test.db");
|
||||
void fall1_leereDb_documentRecordHatAlleErwartetenSpalten(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "fall1_columns_dr.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
Set<String> columns = readColumnNames(jdbcUrl, "document_record");
|
||||
assertThat(columns).containsExactlyInAnyOrder(
|
||||
"id",
|
||||
"fingerprint",
|
||||
"last_known_source_locator",
|
||||
"last_known_source_file_name",
|
||||
"overall_status",
|
||||
"content_error_count",
|
||||
"transient_error_count",
|
||||
"last_failure_instant",
|
||||
"last_success_instant",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"last_target_path",
|
||||
"last_target_file_name"
|
||||
Set<String> spalten = readColumnNames(jdbcUrl, "document_record");
|
||||
assertThat(spalten).containsExactlyInAnyOrder(
|
||||
"id", "fingerprint", "last_known_source_locator", "last_known_source_file_name",
|
||||
"overall_status", "content_error_count", "transient_error_count",
|
||||
"last_failure_instant", "last_success_instant", "created_at", "updated_at",
|
||||
"last_target_path", "last_target_file_name"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializeSchema_processingAttemptHasAllMandatoryColumns(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "attempt_columns_test.db");
|
||||
void fall1_leereDb_processingAttemptHatAlleErwartetenSpalten(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "fall1_columns_pa.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
|
||||
assertThat(columns).containsExactlyInAnyOrder(
|
||||
"id",
|
||||
"fingerprint",
|
||||
"run_id",
|
||||
"attempt_number",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"status",
|
||||
"failure_class",
|
||||
"failure_message",
|
||||
"retryable",
|
||||
"model_name",
|
||||
"prompt_identifier",
|
||||
"processed_page_count",
|
||||
"sent_character_count",
|
||||
"ai_raw_response",
|
||||
"ai_reasoning",
|
||||
"resolved_date",
|
||||
"date_source",
|
||||
"validated_title",
|
||||
"final_target_file_name",
|
||||
"ai_provider"
|
||||
Set<String> spalten = readColumnNames(jdbcUrl, "processing_attempt");
|
||||
assertThat(spalten).containsExactlyInAnyOrder(
|
||||
"id", "fingerprint", "run_id", "attempt_number", "started_at", "ended_at",
|
||||
"status", "failure_class", "failure_message", "retryable",
|
||||
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
|
||||
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
|
||||
"validated_title", "final_target_file_name", "ai_provider"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Idempotency
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void initializeSchema_isIdempotent_calledTwice(@TempDir Path dir) {
|
||||
String jdbcUrl = jdbcUrl(dir, "idempotent_test.db");
|
||||
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||
void fall1_leereDb_indizesVorhanden(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "fall1_indexes.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
// Must not throw on second call
|
||||
adapter.initializeSchema();
|
||||
adapter.initializeSchema();
|
||||
Set<String> indizes = readIndexNames(jdbcUrl);
|
||||
assertThat(indizes).contains(
|
||||
"idx_processing_attempt_fingerprint",
|
||||
"idx_processing_attempt_run_id",
|
||||
"idx_document_record_overall_status"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Leer" bedeutet: keine Tabellen vorhanden – NICHT nur Dateigröße 0 Byte.
|
||||
* Eine leere SQLite-Datei (0 Byte) muss als leere DB erkannt werden.
|
||||
*/
|
||||
@Test
|
||||
void fall1_erkenntLeereDbAuchBeiDateiOhneInhalt(@TempDir Path dir) throws Exception {
|
||||
// Leere Datei anlegen (0 Byte)
|
||||
Path dbPath = dir.resolve("empty.db");
|
||||
Files.createFile(dbPath);
|
||||
assertThat(dbPath).exists();
|
||||
|
||||
String jdbcUrl = jdbcUrl(dir, "empty.db");
|
||||
// Muss als Fall 1 behandelt werden und erfolgreich durchlaufen
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
Set<String> tabellen = readTableNames(jdbcUrl);
|
||||
assertThat(tabellen).contains("document_record", "processing_attempt");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unique constraint: fingerprint in document_record
|
||||
// Fall 2: Bestehende DB ohne Flyway-History – Baseline eintragen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void documentRecord_fingerprintUniqueConstraintIsEnforced(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "unique_test.db");
|
||||
void fall2_bestehendeDbOhneHistory_traegtBaseline_einUndLaeuftErfolgreich(@TempDir Path dir)
|
||||
throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "fall2.db");
|
||||
// Vollständiges konformes Schema anlegen (wie eine bestehende Produktions-DB)
|
||||
erstelleKonformesSchema(jdbcUrl);
|
||||
|
||||
// Adapter muss als Fall 2 erkennen und Baseline eintragen
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
String insertSql = """
|
||||
INSERT INTO document_record
|
||||
(fingerprint, last_known_source_locator, last_known_source_file_name,
|
||||
overall_status, created_at, updated_at)
|
||||
VALUES (?, 'locator', 'file.pdf', 'SUCCESS', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
|
||||
""";
|
||||
// Flyway-History-Tabelle muss jetzt vorhanden sein
|
||||
Set<String> tabellen = readTableNames(jdbcUrl);
|
||||
assertThat(tabellen).contains("flyway_schema_history");
|
||||
// Fachliche Daten müssen erhalten bleiben
|
||||
assertThat(tabellen).contains("document_record", "processing_attempt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fall2_bestehendeDbOhneHistory_erstelltDatiertesBackup(@TempDir Path dir)
|
||||
throws Exception {
|
||||
Path dbPath = dir.resolve("fall2_backup.db");
|
||||
String jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath().toString().replace('\\', '/');
|
||||
erstelleKonformesSchema(jdbcUrl);
|
||||
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
// Backup-Datei muss vorhanden sein
|
||||
long backupAnzahl = Files.list(dir)
|
||||
.filter(p -> p.getFileName().toString().startsWith("fall2_backup.db.")
|
||||
&& p.getFileName().toString().endsWith(".bak"))
|
||||
.count();
|
||||
assertThat(backupAnzahl).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fall2_bestehendeDbMitFehlendemElement_brichtMitFehlerAb(@TempDir Path dir) {
|
||||
String jdbcUrl = jdbcUrl(dir, "fall2_broken.db");
|
||||
// Schema ohne Spalte ai_provider anlegen (nicht konform)
|
||||
erstelleSchemaOhneAiProvider(jdbcUrl);
|
||||
|
||||
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema())
|
||||
.isInstanceOf(DocumentPersistenceException.class)
|
||||
.hasMessageContaining("ai_provider");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fall2_bestehendeDbOhneProcessingAttemptTabelle_brichtAb(@TempDir Path dir) {
|
||||
String jdbcUrl = jdbcUrl(dir, "fall2_no_attempt.db");
|
||||
// Nur document_record anlegen, processing_attempt fehlt
|
||||
erstelleNurDocumentRecord(jdbcUrl);
|
||||
|
||||
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema())
|
||||
.isInstanceOf(DocumentPersistenceException.class)
|
||||
.hasMessageContaining("processing_attempt");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fall 3: Folgestart mit Flyway-History – idempotent
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void fall3_folgestart_laeuftIdempotentOhneException(@TempDir Path dir) {
|
||||
String jdbcUrl = jdbcUrl(dir, "fall3.db");
|
||||
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||
|
||||
assertThatCode(() -> {
|
||||
// Erster Aufruf (Fall 1)
|
||||
adapter.initializeSchema();
|
||||
// Zweiter Aufruf (Fall 3) – darf nicht werfen
|
||||
adapter.initializeSchema();
|
||||
// Dritter Aufruf (Fall 3) – ebenfalls idempotent
|
||||
adapter.initializeSchema();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fall3_folgestart_fachlicheDatenBleiben(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "fall3_data.db");
|
||||
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||
adapter.initializeSchema();
|
||||
|
||||
// Testdatensatz einfügen
|
||||
String fp = "a".repeat(64);
|
||||
insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS");
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
||||
try (var ps = conn.prepareStatement(insertSql)) {
|
||||
ps.setString(1, fp);
|
||||
// Folgestart
|
||||
adapter.initializeSchema();
|
||||
|
||||
// Daten müssen erhalten bleiben
|
||||
assertThat(leseStatus(jdbcUrl, fp)).isEqualTo("SUCCESS");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PRAGMA foreign_keys – Foreign-Key-Aktivierung via DataSource
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void foreignKeys_sindNachSchemaInitAktiv(@TempDir Path dir) throws Exception {
|
||||
String jdbcUrl = jdbcUrl(dir, "fk_test.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
// Neue Verbindung über SQLiteConfig aufbauen (wie der Adapter es tut)
|
||||
org.sqlite.SQLiteConfig config = new org.sqlite.SQLiteConfig();
|
||||
config.enforceForeignKeys(true);
|
||||
org.sqlite.SQLiteDataSource ds = new org.sqlite.SQLiteDataSource(config);
|
||||
ds.setUrl(jdbcUrl);
|
||||
|
||||
try (Connection conn = ds.getConnection();
|
||||
var stmt = conn.createStatement()) {
|
||||
// PRAGMA foreign_keys muss 1 zurückliefern
|
||||
ResultSet rs = stmt.executeQuery("PRAGMA foreign_keys");
|
||||
assertThat(rs.next()).isTrue();
|
||||
assertThat(rs.getInt(1)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void foreignKeys_verletzungWirdDurchgesetzt(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "fk_enforced.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
// Versuch, einen processing_attempt ohne passendem document_record einzufügen
|
||||
org.sqlite.SQLiteConfig config = new org.sqlite.SQLiteConfig();
|
||||
config.enforceForeignKeys(true);
|
||||
org.sqlite.SQLiteDataSource ds = new org.sqlite.SQLiteDataSource(config);
|
||||
ds.setUrl(jdbcUrl);
|
||||
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
assertThatThrownBy(() -> insertOrphanedProcessingAttempt(conn))
|
||||
.isInstanceOf(SQLException.class);
|
||||
}
|
||||
}
|
||||
|
||||
private static void insertOrphanedProcessingAttempt(Connection conn) throws SQLException {
|
||||
try (var ps = conn.prepareStatement("""
|
||||
INSERT INTO processing_attempt
|
||||
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
|
||||
VALUES ('nichtvorhanden', 'run-1', 1, '2026-01-01T00:00:00Z',
|
||||
'2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1)
|
||||
""")) {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
// Second insert with same fingerprint must fail
|
||||
try (var ps = conn.prepareStatement(insertSql)) {
|
||||
ps.setString(1, fp);
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
SQLException.class, ps::executeUpdate,
|
||||
"Expected UNIQUE constraint violation on document_record.fingerprint");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unique constraint: (fingerprint, attempt_number) in processing_attempt
|
||||
// Eindeutigkeits-Constraints
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void processingAttempt_fingerprintAttemptNumberUniqueConstraintIsEnforced(@TempDir Path dir)
|
||||
void documentRecord_fingerprintUniqueConstraintWirdDurchgesetzt(@TempDir Path dir)
|
||||
throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "attempt_unique_test.db");
|
||||
String jdbcUrl = jdbcUrl(dir, "unique_dr.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
String fp = "b".repeat(64);
|
||||
insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS");
|
||||
|
||||
// Insert master record first (FK)
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
||||
try (var ps = conn.prepareStatement("""
|
||||
INSERT INTO document_record
|
||||
(fingerprint, last_known_source_locator, last_known_source_file_name,
|
||||
overall_status, created_at, updated_at)
|
||||
VALUES (?, 'loc', 'f.pdf', 'FAILED_RETRYABLE', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
|
||||
""")) {
|
||||
ps.setString(1, fp);
|
||||
ps.executeUpdate();
|
||||
// Zweiter Insert mit gleichem Fingerprint muss fehlschlagen
|
||||
assertThatThrownBy(() -> insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS"))
|
||||
.isInstanceOf(SQLException.class);
|
||||
}
|
||||
|
||||
String attemptSql = """
|
||||
INSERT INTO processing_attempt
|
||||
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
|
||||
VALUES (?, 'run-1', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1)
|
||||
""";
|
||||
|
||||
try (var ps = conn.prepareStatement(attemptSql)) {
|
||||
ps.setString(1, fp);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
// Duplicate (fingerprint, attempt_number) must fail
|
||||
try (var ps = conn.prepareStatement(attemptSql)) {
|
||||
ps.setString(1, fp);
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
SQLException.class, ps::executeUpdate,
|
||||
"Expected UNIQUE constraint violation on (fingerprint, attempt_number)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Skip attempts are storable
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void processingAttempt_skipStatusIsStorable(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "skip_test.db");
|
||||
void processingAttempt_fingerprintUndAttemptNumberUniqueConstraintWirdDurchgesetzt(
|
||||
@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "unique_pa.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
String fp = "c".repeat(64);
|
||||
insertiereDocumentRecord(jdbcUrl, fp, "FAILED_RETRYABLE");
|
||||
insertiereProcessingAttempt(jdbcUrl, fp, 1);
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
||||
// Insert master record
|
||||
try (var ps = conn.prepareStatement("""
|
||||
INSERT INTO document_record
|
||||
(fingerprint, last_known_source_locator, last_known_source_file_name,
|
||||
overall_status, created_at, updated_at)
|
||||
VALUES (?, 'loc', 'f.pdf', 'SUCCESS', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
|
||||
""")) {
|
||||
ps.setString(1, fp);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
// Insert a SKIPPED_ALREADY_PROCESSED attempt (null failure fields, retryable=0)
|
||||
try (var ps = conn.prepareStatement("""
|
||||
INSERT INTO processing_attempt
|
||||
(fingerprint, run_id, attempt_number, started_at, ended_at,
|
||||
status, failure_class, failure_message, retryable)
|
||||
VALUES (?, 'run-2', 2, '2026-01-02T00:00:00Z', '2026-01-02T00:00:01Z',
|
||||
'SKIPPED_ALREADY_PROCESSED', NULL, NULL, 0)
|
||||
""")) {
|
||||
ps.setString(1, fp);
|
||||
int rows = ps.executeUpdate();
|
||||
assertThat(rows).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
// Zweiter Insert mit gleicher (fingerprint, attempt_number) muss fehlschlagen
|
||||
assertThatThrownBy(() -> insertiereProcessingAttempt(jdbcUrl, fp, 1))
|
||||
.isInstanceOf(SQLException.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Schema evolution — AI traceability columns
|
||||
// Fehlerfall: ungültige URL
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void initializeSchema_addsAiTraceabilityColumnsToExistingSchema(@TempDir Path dir)
|
||||
throws SQLException {
|
||||
// Simulate a pre-evolution schema: create the base tables without AI columns
|
||||
String jdbcUrl = jdbcUrl(dir, "evolution_test.db");
|
||||
void initializeSchema_wirftDocumentPersistenceException_beiUngueltigerUrl() {
|
||||
SqliteSchemaInitializationAdapter adapter =
|
||||
new SqliteSchemaInitializationAdapter("keine-jdbc-url");
|
||||
assertThatThrownBy(adapter::initializeSchema)
|
||||
.isInstanceOf(DocumentPersistenceException.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden – Schema-Erstellung für Tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Erstellt ein vollständig konformes Schema (entspricht V1-Zielschema) ohne Flyway-History.
|
||||
*/
|
||||
private static void erstelleKonformesSchema(String jdbcUrl) {
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
var stmt = conn.createStatement()) {
|
||||
stmt.execute("PRAGMA foreign_keys = ON");
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS document_record (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -276,6 +341,8 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
last_success_instant TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_target_path TEXT,
|
||||
last_target_file_name TEXT,
|
||||
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
|
||||
)
|
||||
""");
|
||||
@@ -290,112 +357,118 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
status TEXT NOT NULL,
|
||||
failure_class TEXT,
|
||||
failure_message TEXT,
|
||||
retryable INTEGER NOT NULL DEFAULT 0
|
||||
retryable INTEGER NOT NULL DEFAULT 0,
|
||||
model_name TEXT,
|
||||
prompt_identifier TEXT,
|
||||
processed_page_count INTEGER,
|
||||
sent_character_count INTEGER,
|
||||
ai_raw_response TEXT,
|
||||
ai_reasoning TEXT,
|
||||
resolved_date TEXT,
|
||||
date_source TEXT,
|
||||
validated_title TEXT,
|
||||
final_target_file_name TEXT,
|
||||
ai_provider TEXT,
|
||||
CONSTRAINT fk_processing_attempt_fingerprint
|
||||
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
|
||||
CONSTRAINT uq_processing_attempt_fingerprint_number
|
||||
UNIQUE (fingerprint, attempt_number)
|
||||
)
|
||||
""");
|
||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint ON processing_attempt (fingerprint)");
|
||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_processing_attempt_run_id ON processing_attempt (run_id)");
|
||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_document_record_overall_status ON document_record (overall_status)");
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Testvorbereitungsfehler: Schema konnte nicht erstellt werden", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Running initializeSchema on the existing base schema must succeed (evolution)
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
/**
|
||||
* Erstellt ein Schema ohne die Spalte {@code ai_provider} in {@code processing_attempt}.
|
||||
*/
|
||||
private static void erstelleSchemaOhneAiProvider(String jdbcUrl) {
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
var stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
CREATE TABLE document_record (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
last_known_source_locator TEXT NOT NULL,
|
||||
last_known_source_file_name TEXT NOT NULL,
|
||||
overall_status TEXT NOT NULL,
|
||||
content_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
transient_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_failure_instant TEXT,
|
||||
last_success_instant TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_target_path TEXT,
|
||||
last_target_file_name TEXT,
|
||||
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
|
||||
)
|
||||
""");
|
||||
// processing_attempt OHNE ai_provider
|
||||
stmt.execute("""
|
||||
CREATE TABLE processing_attempt (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
failure_class TEXT,
|
||||
failure_message TEXT,
|
||||
retryable INTEGER NOT NULL DEFAULT 0,
|
||||
model_name TEXT,
|
||||
prompt_identifier TEXT,
|
||||
processed_page_count INTEGER,
|
||||
sent_character_count INTEGER,
|
||||
ai_raw_response TEXT,
|
||||
ai_reasoning TEXT,
|
||||
resolved_date TEXT,
|
||||
date_source TEXT,
|
||||
validated_title TEXT,
|
||||
final_target_file_name TEXT
|
||||
)
|
||||
""");
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Testvorbereitungsfehler", e);
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
|
||||
assertThat(columns).contains(
|
||||
"model_name", "prompt_identifier", "processed_page_count",
|
||||
"sent_character_count", "ai_raw_response", "ai_reasoning",
|
||||
"resolved_date", "date_source", "validated_title");
|
||||
/**
|
||||
* Erstellt nur die Tabelle {@code document_record} (ohne {@code processing_attempt}).
|
||||
*/
|
||||
private static void erstelleNurDocumentRecord(String jdbcUrl) {
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
var stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
CREATE TABLE document_record (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
last_known_source_locator TEXT NOT NULL,
|
||||
last_known_source_file_name TEXT NOT NULL,
|
||||
overall_status TEXT NOT NULL,
|
||||
content_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
transient_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_failure_instant TEXT,
|
||||
last_success_instant TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""");
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Testvorbereitungsfehler", e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status migration — earlier positive intermediate state → READY_FOR_AI
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void initializeSchema_migrates_legacySuccessWithoutProposal_toReadyForAi(@TempDir Path dir)
|
||||
throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "migration_test.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
// Insert a document with SUCCESS status and no PROPOSAL_READY attempt
|
||||
String fp = "d".repeat(64);
|
||||
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
|
||||
|
||||
// Run schema initialisation again (migration step runs every time)
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
String status = readOverallStatus(jdbcUrl, fp);
|
||||
assertThat(status).isEqualTo("READY_FOR_AI");
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializeSchema_migration_isIdempotent(@TempDir Path dir) throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "migration_idempotent_test.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
String fp = "e".repeat(64);
|
||||
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
|
||||
|
||||
// Run migration twice — must not corrupt data or throw
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
String status = readOverallStatus(jdbcUrl, fp);
|
||||
assertThat(status).isEqualTo("READY_FOR_AI");
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializeSchema_doesNotMigrate_successWithProposalReadyAttempt(@TempDir Path dir)
|
||||
throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "migration_proposal_test.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
String fp = "f".repeat(64);
|
||||
// SUCCESS document that already has a PROPOSAL_READY attempt must NOT be migrated
|
||||
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
|
||||
insertAttemptWithStatus(jdbcUrl, fp, "PROPOSAL_READY");
|
||||
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
String status = readOverallStatus(jdbcUrl, fp);
|
||||
assertThat(status).isEqualTo("SUCCESS");
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializeSchema_doesNotMigrate_terminalFailureStates(@TempDir Path dir)
|
||||
throws SQLException {
|
||||
String jdbcUrl = jdbcUrl(dir, "migration_failure_test.db");
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
String fpRetryable = "1".repeat(64);
|
||||
String fpFinal = "2".repeat(64);
|
||||
insertDocumentRecordWithStatus(jdbcUrl, fpRetryable, "FAILED_RETRYABLE");
|
||||
insertDocumentRecordWithStatus(jdbcUrl, fpFinal, "FAILED_FINAL");
|
||||
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||
|
||||
assertThat(readOverallStatus(jdbcUrl, fpRetryable)).isEqualTo("FAILED_RETRYABLE");
|
||||
assertThat(readOverallStatus(jdbcUrl, fpFinal)).isEqualTo("FAILED_FINAL");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void initializeSchema_throwsDocumentPersistenceException_onInvalidUrl() {
|
||||
// SQLite is lenient with paths; use a truly invalid JDBC URL format
|
||||
SqliteSchemaInitializationAdapter badAdapter =
|
||||
new SqliteSchemaInitializationAdapter("not-a-jdbc-url-at-all");
|
||||
|
||||
assertThatThrownBy(badAdapter::initializeSchema)
|
||||
.isInstanceOf(DocumentPersistenceException.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// Hilfsmethoden – JDBC
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static String jdbcUrl(Path dir, String filename) {
|
||||
return "jdbc:sqlite:" + dir.resolve(filename).toAbsolutePath();
|
||||
return "jdbc:sqlite:" + dir.resolve(filename).toAbsolutePath().toString().replace('\\', '/');
|
||||
}
|
||||
|
||||
private static Set<String> readTableNames(String jdbcUrl) throws SQLException {
|
||||
@@ -411,7 +484,8 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
return tables;
|
||||
}
|
||||
|
||||
private static Set<String> readColumnNames(String jdbcUrl, String tableName) throws SQLException {
|
||||
private static Set<String> readColumnNames(String jdbcUrl, String tableName)
|
||||
throws SQLException {
|
||||
Set<String> columns = new HashSet<>();
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
||||
DatabaseMetaData meta = conn.getMetaData();
|
||||
@@ -424,7 +498,25 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static void insertDocumentRecordWithStatus(String jdbcUrl, String fingerprint,
|
||||
private static Set<String> readIndexNames(String jdbcUrl) throws SQLException {
|
||||
Set<String> indexes = new HashSet<>();
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
||||
DatabaseMetaData meta = conn.getMetaData();
|
||||
for (String table : new String[]{"document_record", "processing_attempt"}) {
|
||||
try (ResultSet rs = meta.getIndexInfo(null, null, table, false, false)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("INDEX_NAME");
|
||||
if (name != null) {
|
||||
indexes.add(name.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
private static void insertiereDocumentRecord(String jdbcUrl, String fingerprint,
|
||||
String status) throws SQLException {
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
var ps = conn.prepareStatement("""
|
||||
@@ -439,21 +531,22 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void insertAttemptWithStatus(String jdbcUrl, String fingerprint,
|
||||
String status) throws SQLException {
|
||||
private static void insertiereProcessingAttempt(String jdbcUrl, String fingerprint,
|
||||
int attemptNumber) throws SQLException {
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
var ps = conn.prepareStatement("""
|
||||
INSERT INTO processing_attempt
|
||||
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
|
||||
VALUES (?, 'run-1', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', ?, 0)
|
||||
VALUES (?, 'run-1', ?, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z',
|
||||
'FAILED_RETRYABLE', 1)
|
||||
""")) {
|
||||
ps.setString(1, fingerprint);
|
||||
ps.setString(2, status);
|
||||
ps.setInt(2, attemptNumber);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static String readOverallStatus(String jdbcUrl, String fingerprint) throws SQLException {
|
||||
private static String leseStatus(String jdbcUrl, String fingerprint) throws SQLException {
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
var ps = conn.prepareStatement(
|
||||
"SELECT overall_status FROM document_record WHERE fingerprint = ?")) {
|
||||
@@ -462,7 +555,7 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
if (rs.next()) {
|
||||
return rs.getString("overall_status");
|
||||
}
|
||||
throw new IllegalStateException("No document record found for fingerprint: " + fingerprint);
|
||||
throw new IllegalStateException("Kein Eintrag für Fingerprint: " + fingerprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user