Compare commits
123 Commits
016da8318d
...
3.0.239
| Author | SHA1 | Date | |
|---|---|---|---|
| 406eac80e4 | |||
| 4fba3379b9 | |||
| 9307a18e04 | |||
| 6a5ae4e7b0 | |||
| 479d176536 | |||
| bd2be347f6 | |||
| 18f9c33bbb | |||
| 349ee69a7f | |||
| 3b3e997d13 | |||
| ddfbf9b8cb | |||
| 0b69adf8c9 | |||
| 31c65fb9fd | |||
| 4ee0923721 | |||
| 4b89743404 | |||
| 6e03093ce9 | |||
| 51d6168697 | |||
| 46fc1d4fa4 | |||
| 5d5dee0bbf | |||
| 4f5ce4c750 | |||
| dc17824e84 | |||
| 0fe5359299 | |||
| 563d9f52db | |||
| 732d00c4ad | |||
| 500a8c5340 | |||
| c6379c04f6 | |||
| 01e97848a7 | |||
| 8aaa3331d7 | |||
| d10a572b50 | |||
| a87c73401b | |||
| 8ca6d08133 | |||
| cd273505af | |||
| bdc5e8331f | |||
| 330bcfe124 | |||
| c137d9e02e | |||
| ea8b94acc7 | |||
| 4bbee57d41 | |||
| 43c54923f8 | |||
| a910633c64 | |||
| 899525a75c | |||
| 0a139193b4 | |||
| 0da80849d4 | |||
| 014b11abd2 | |||
| 6ff463b7ef | |||
| 8bb0aabb51 | |||
| 27b4292c2f | |||
| 0b5a441a5d | |||
| 3877359b42 | |||
| 769d15fd86 | |||
| 6317a27378 | |||
| 4fa4c152a5 | |||
| ec23b2455a | |||
| 7f2cccf317 | |||
| a5fae8cf55 | |||
| 191d398604 | |||
| f204ad1f1e | |||
| ac3513504d | |||
| 65d8379c15 | |||
| a3642608b4 | |||
| ff86a07f0e | |||
| d9670ddfbe | |||
| 03b23eb6a9 | |||
| 1d77173c49 | |||
| fb0e9809f6 | |||
| c3f8103572 | |||
| 3f5602de01 | |||
| 1db6e27be8 | |||
| 385bda5331 | |||
| 5d4230b4cb | |||
| 3feafcbce8 | |||
| 5165ea6f1d | |||
| 0e20f93c0d | |||
| 234b3461b7 | |||
| 6b078aa3e7 | |||
| 7e2fec4c7b | |||
| 591c7ff94c | |||
| 673023d921 | |||
| 71d79ab30c | |||
| 8f4e18b248 | |||
| 0387be0e96 | |||
| ca16855e81 | |||
| 7e31057bfa | |||
| d3fbfc4094 | |||
| f6b265b370 | |||
| 3a98304a5c | |||
| b87e8498e6 | |||
| 67275eb2f5 | |||
| 955adc0c45 | |||
| e7f5590934 | |||
| c46294159c | |||
| 1df541d0f9 | |||
| 09605ee495 | |||
| 55088354ab | |||
| 83f6d63c27 | |||
| b41b4112c4 | |||
| 9fd5bd5a52 | |||
| f4a1bce9ae | |||
| 5d0e2c90bd | |||
| c61108fe1b | |||
| d1cffe8ef9 | |||
| 2e6d0b1d6d | |||
| 34c8245ae9 | |||
| c7f53416ca | |||
| 20a14b3c62 | |||
| f4cfb5cbc0 | |||
| eacc205865 | |||
| 566a7b97dd | |||
| d1fa989016 | |||
| 4875a1ed42 | |||
| 0f07947879 | |||
| 8884d15e69 | |||
| 3e1f59fd12 | |||
| 13e4922272 | |||
| 1996f31f43 | |||
| e07b460cdd | |||
| 9ba32f1bb8 | |||
| 8286d0f0e5 | |||
| 088fd85572 | |||
| 8be1848ba9 | |||
| aaedc2d713 | |||
| ada7e203e3 | |||
| 6babdd226e | |||
| 202088d1d3 | |||
| 523774707b |
@@ -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
|
||||
@@ -75,3 +77,4 @@ replay_pid*
|
||||
/run-milestone.ps1
|
||||
/run-v11.ps1
|
||||
.m2repo
|
||||
/start-headless.bat
|
||||
|
||||
@@ -11,9 +11,18 @@ Ab V2.0 wird die Anwendung um eine **lokale JavaFX-Desktop-GUI** erweitert. Die
|
||||
@docs/specs/meilensteine-v2_0.md
|
||||
|
||||
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:
|
||||
|
||||
@@ -49,7 +58,8 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
|
||||
- kein Applikationsserver
|
||||
- keine Dauerlauf-Anwendung
|
||||
- kein interner Scheduler
|
||||
- keine EXE, kein Installer
|
||||
- das Shade-JAR bleibt das primäre Distributionsartefakt
|
||||
- zusätzlicher nativer Windows-Installer (MSI) ab V3.0 via Maven-Profil `release` (jpackage, WiX Toolset 3.x im PATH erforderlich); der Normalbuild `mvn clean verify` bleibt vom Profil unberührt und benötigt kein WiX
|
||||
- Log4j2 für Logging
|
||||
- SQLite als lokaler Persistenzspeicher
|
||||
- JavaFX wird mit dem JAR ausgeliefert (kein separates JavaFX-Setup)
|
||||
@@ -120,9 +130,9 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
|
||||
## Globale fachliche Leitplanken
|
||||
- Zielformat: `YYYY-MM-DD - Titel.pdf`
|
||||
- Bei Namenskollisionen: `YYYY-MM-DD - Titel(1).pdf`, `YYYY-MM-DD - Titel(2).pdf`, ...
|
||||
- Die **20 Zeichen** gelten nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit
|
||||
- Die **konfigurierte maximale Titellänge** gilt nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit
|
||||
- Das Dubletten-Suffix wird unmittelbar vor `.pdf` angehängt
|
||||
- Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen
|
||||
- Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen, Bindestrichen, Punkten, Kommas und Ampersands
|
||||
- Eigennamen bleiben unverändert
|
||||
- Datumsermittlung mit Priorität aus den fachlichen Anforderungen; wenn kein belastbares Datum eindeutig ableitbar ist, ist das **aktuelle Datum** als Fallback erlaubt
|
||||
- Mehrdeutige Dokumente liefern **kein** unsicheres Ergebnis, sondern einen Fehler
|
||||
@@ -135,9 +145,13 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
|
||||
## Aktiver Implementierungsstand
|
||||
V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben.
|
||||
|
||||
Der aktive Entwicklungsstand ist **V2.0**. Ziel ist der Ausbau um eine lokale JavaFX-Desktop-GUI als neuen Standardstart, ohne die bestehende Architektur, das Standalone-JAR-Betriebsmodell oder den headless Scheduler-Betrieb aufzugeben.
|
||||
Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technische Tests) ist abgeschlossen.
|
||||
|
||||
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt in V2.0 unverändert.
|
||||
**V2.9 ist abgeschlossen.** Der Tab „Verarbeitungslauf" wurde erweitert um eine integrierte PDF-Vorschau (Lazy-Rendering direkt über PDFBox, In-Memory-Cache, Seitennavigation) sowie einen editierbaren Dateiname-Bereich mit Live-Validierung, Dirty-State-Dialog und atomarer Dateisystem-/DB-Transaktion inklusive Rollback und Fingerprint-basierter Konfliktauflösung. Die zugehörigen neuen Ports, Use Cases und Adapter sind in den modularen Architektur-Übersichten beschrieben.
|
||||
|
||||
Verhaltensänderungen seit V2.9: Die GUI startet maximiert, und die zuletzt geladene Konfigurationsdatei wird beim Start automatisch wieder geladen; existiert sie nicht mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
||||
|
||||
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert.
|
||||
|
||||
## Statussemantik
|
||||
|
||||
@@ -226,6 +240,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
|
||||
@@ -253,6 +274,8 @@ Ein Arbeitspaket ist erst fertig, wenn:
|
||||
- Nach Änderungen den kleinsten sinnvollen Build-/Test-Umfang ausführen
|
||||
- Build-Validierung vom Parent-Root (Beispiel für vollständigen Reactor-Build ab V2.0):
|
||||
`.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make`
|
||||
- MSI-Build (nur lokal auf der Entwicklungsmaschine, WiX Toolset 3.x im PATH erforderlich):
|
||||
`.\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests`
|
||||
- Schlägt der Build fehl: Fehler beheben, erneut bauen, erst dann weiter
|
||||
- Vor Abschluss sicherstellen, dass der relevante Maven-Reactor-Stand fehlerfrei ist
|
||||
- Fehler nicht kaschieren; Ursachen sauber beheben oder offen benennen
|
||||
@@ -273,6 +296,7 @@ Verbindlich zweckmäßige Parameter:
|
||||
- `max.retries.transient` – max. historisierte transiente Fehlversuche pro Fingerprint (**Integer >= 1**, `0` ist ungültig)
|
||||
- `max.pages` – Seitenlimit
|
||||
- `max.text.characters` – maximale Zeichenzahl für KI-Eingabe
|
||||
- `max.title.length` – maximale Länge des Basistitels in Zeichen (gültiger Bereich 10..120, Default 60)
|
||||
- `prompt.template.file` – externe Prompt-Datei
|
||||
- `log.ai.sensitive` – sensible KI-Logausgabe freischalten (Boolean, Default: `false`)
|
||||
- `runtime.lock.file` – Lock-Datei (optional)
|
||||
@@ -306,11 +330,10 @@ Verbindlicher Ablauf:
|
||||
6. Erst danach den normalen Lauf fortsetzen
|
||||
|
||||
## Nicht-Ziele / Verbote
|
||||
- kein manueller Verarbeitungslauf aus der GUI (erst V2.1+)
|
||||
- kein manueller Verarbeitungslauf aus der GUI (kein vollständiger Lauf; Bearbeitungen nach Lauf sind zulässig)
|
||||
- kein DB-/Historien-Tab in der GUI (erst V2.x+)
|
||||
- kein Kosten-Tracking (erst V2.x+)
|
||||
- kein echter Mini-KI-Testaufruf mit fachlicher Antwortauswertung
|
||||
- keine EXE, kein Installer
|
||||
- kein Web-UI
|
||||
- keine REST-API zur Bedienung
|
||||
- keine OCR innerhalb der Java-Anwendung
|
||||
|
||||
Vendored
+184
@@ -0,0 +1,184 @@
|
||||
// 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('Publish PIT Coverage') {
|
||||
steps {
|
||||
recordCoverage(
|
||||
tools: [[
|
||||
parser: 'PIT',
|
||||
pattern: '**/target/pit-reports/mutations.xml'
|
||||
]],
|
||||
id: 'pit',
|
||||
name: 'PIT Mutation Coverage',
|
||||
failOnError: true
|
||||
)
|
||||
}
|
||||
} // stage: Publish PIT Coverage
|
||||
|
||||
stage('Archive JAR') {
|
||||
steps {
|
||||
// Bash wird explizit erzwungen, weil Jenkins-Agenten standardmäßig
|
||||
// sh (dash) verwenden, das kein mapfile kennt. mapfile zählt exakt
|
||||
// die gefundenen Shade-JARs und bricht ab, wenn nicht genau eines vorhanden ist.
|
||||
sh '''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
mapfile -t JARS < <(find pdf-umbenenner-bootstrap/target \
|
||||
-maxdepth 1 -name "pdf-umbenenner-bootstrap-*.jar" \
|
||||
! -name "*-sources.jar" ! -name "*-javadoc.jar")
|
||||
|
||||
test "${#JARS[@]}" -eq 1 \
|
||||
|| { echo "FEHLER: Erwartet genau 1 Shade-JAR, gefunden: ${#JARS[@]}"; exit 1; }
|
||||
|
||||
JAR_NAME="pdf-ki-renamer-${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}.jar"
|
||||
cp "${JARS[0]}" "$JAR_NAME"
|
||||
echo "Shade-JAR archiviert als: $JAR_NAME"
|
||||
'''
|
||||
archiveArtifacts artifacts: 'pdf-ki-renamer-*.jar', fingerprint: true
|
||||
}
|
||||
} // stage: Archive JAR
|
||||
|
||||
stage('Artefakt ablegen') {
|
||||
steps {
|
||||
sh '''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_DIR="/builds/${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
cp pdf-ki-renamer-*.jar "$BUILD_DIR/"
|
||||
echo "Artefakt abgelegt unter: $BUILD_DIR"
|
||||
'''
|
||||
}
|
||||
} // stage: Artefakt ablegen
|
||||
|
||||
stage('Berichte veröffentlichen') {
|
||||
steps {
|
||||
junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true
|
||||
|
||||
recordCoverage(
|
||||
tools: [[parser: 'JACOCO', pattern: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate/jacoco.xml']],
|
||||
enabledForFailure: true
|
||||
)
|
||||
|
||||
publishHTML(target: [
|
||||
reportName: 'JaCoCo HTML Report',
|
||||
reportDir: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate',
|
||||
reportFiles: 'index.html',
|
||||
keepAll: true,
|
||||
alwaysLinkToLastBuild: true,
|
||||
allowMissing: true
|
||||
])
|
||||
}
|
||||
} // stage: Berichte veröffentlichen
|
||||
|
||||
stage('Aufräumen') {
|
||||
steps {
|
||||
sh '''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
rm -f pdf-ki-renamer-*.jar
|
||||
echo "Aufräumen abgeschlossen."
|
||||
'''
|
||||
}
|
||||
} // stage: Aufräumen
|
||||
|
||||
} // stages
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} erfolgreich abgeschlossen."
|
||||
}
|
||||
failure {
|
||||
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} fehlgeschlagen."
|
||||
}
|
||||
always {
|
||||
deleteDir()
|
||||
}
|
||||
}
|
||||
|
||||
} // pipeline
|
||||
@@ -4,8 +4,10 @@ Ein lokal gestartetes Java-Programm zur KI-gestützten Umbenennung bereits OCR-v
|
||||
|
||||
Die Anwendung liest PDF-Dateien aus einem konfigurierbaren Quellordner, extrahiert den Text, ermittelt daraus per KI einen normierten Dateinamen und legt **eine Kopie** im Zielordner ab. Die Quelldateien bleiben unverändert.
|
||||
|
||||
> **V2.0:** Die Anwendung enthält ab V2.0 eine lokale JavaFX-Desktop-GUI als Standardstart.
|
||||
> Die GUI dient der Konfiguration, Validierung und technischen Diagnose.
|
||||
> **V2.9:** Die Anwendung enthält eine lokale JavaFX-Desktop-GUI als Standardstart.
|
||||
> Die GUI dient der Konfiguration, Validierung, technischen Diagnose und der Ausführung von Verarbeitungsläufen.
|
||||
> Der Tab „Verarbeitungslauf" enthält eine integrierte PDF-Vorschau und einen editierbaren Dateiname-Bereich.
|
||||
> Die GUI startet maximiert und lädt beim Start automatisch die zuletzt verwendete Konfigurationsdatei.
|
||||
> Der headless Batch-Betrieb bleibt über `--headless` vollständig erhalten.
|
||||
> Details zum Betrieb: [`docs/betrieb.md`](docs/betrieb.md)
|
||||
|
||||
@@ -59,8 +61,8 @@ YYYY-MM-DD - Titel(2).pdf
|
||||
|
||||
Wichtige Regeln:
|
||||
|
||||
- die **20 Zeichen** beziehen sich nur auf den **Basistitel**
|
||||
- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen
|
||||
- die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel**
|
||||
- das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge
|
||||
- Titel werden auf **Deutsch** erzeugt
|
||||
- Eigennamen bleiben unverändert
|
||||
- Quelldateien werden **nie** überschrieben, verschoben oder verändert
|
||||
@@ -118,6 +120,7 @@ Typische Bereiche sind:
|
||||
- Timeout
|
||||
- Seitenlimit
|
||||
- Textlimit für KI-Aufrufe
|
||||
- maximale Titellänge (`max.title.length`, Default 60, Bereich 10..120)
|
||||
- Prompt-Datei
|
||||
- Logging
|
||||
|
||||
@@ -211,7 +214,7 @@ Empfohlene Leserichtung:
|
||||
|
||||
## Status des Projekts
|
||||
|
||||
Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der aktuelle Produktstand (V2.0) baut auf einem vollständig implementierten Kern für:
|
||||
Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der aktuelle Produktstand baut auf einem vollständig implementierten Kern für:
|
||||
|
||||
- Konfiguration und Startvalidierung
|
||||
- Quellordner-Scan und PDF-Textauslese
|
||||
@@ -220,6 +223,8 @@ Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der a
|
||||
- Dateinamensbildung und Zielkopie
|
||||
- Retry-Logik, Logging und betriebliche Robustheit
|
||||
- JavaFX-Desktop-GUI als Standardstart (Konfigurationseditor, Validierung, technische Tests)
|
||||
- Tab „Verarbeitungslauf" mit integrierter PDF-Vorschau pro Zeile und editierbarem Dateiname-Bereich
|
||||
- Atomare Dateisystem- und Datenbankoperationen für manuelle Umbenennungen mit Konfliktauflösung
|
||||
- headless Batch-Betrieb über `--headless` (rückwärtskompatibel zu V1.x)
|
||||
|
||||
## Lizenz / Nutzung
|
||||
|
||||
@@ -26,7 +26,10 @@ max.retries.transient=3
|
||||
max.pages=10
|
||||
|
||||
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
|
||||
max.text.characters=5000
|
||||
max.text.characters=1000
|
||||
|
||||
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
|
||||
max.title.length=60
|
||||
|
||||
# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator
|
||||
# in der Versuchshistorie.
|
||||
|
||||
@@ -13,6 +13,8 @@ sqlite.file=./work/test/pdf-umbenenner-test.db
|
||||
max.retries.transient=1
|
||||
max.pages=5
|
||||
max.text.characters=2000
|
||||
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
|
||||
max.title.length=60
|
||||
prompt.template.file=./config/prompts/template.txt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,9 +2,35 @@ Du bist ein Assistent zur automatischen Benennung gescannter PDF-Dokumente.
|
||||
|
||||
Analysiere den folgenden Dokumenttext und ermittle:
|
||||
|
||||
1. Einen inhaltlich passenden deutschen Titel (maximal 20 Zeichen, nur Buchstaben und Leerzeichen, keine Abkürzungen, keine generischen Bezeichnungen wie "Dokument", "Datei", "Scan" oder "PDF")
|
||||
1. Einen inhaltlich passenden deutschen Titel nach dem Schema: {Absender} {Betreff_gekürzt}
|
||||
2. Das relevanteste Datum des Dokuments
|
||||
|
||||
Titelschema – verbindlich:
|
||||
- Erster Teil: Absender (Person, Firma, Behörde, Institution) – so wie im Dokument genannt, Abkürzungen wie GmbH, AG, KfW, Kfz sind erlaubt
|
||||
- Zweiter Teil: Betreff oder Kernaussage des Dokuments, so kurz wie möglich – bevorzugt aus einer vorhandenen Betreffzeile, sonst aus dem Dokumentinhalt abgeleitet
|
||||
- Beide Teile durch ein Leerzeichen getrennt, kein Sonderzeichen außer Bindestrich und Leerzeichen
|
||||
- **Maximal {MAX_TITLE_LENGTH} Zeichen gesamt – diese Grenze ist nicht verhandelbar und MUSS eingehalten werden**
|
||||
- Keine generischen Begriffe wie "Dokument", "Datei", "Scan", "PDF", "Schreiben", "Brief"
|
||||
- Titel auf Deutsch formulieren
|
||||
|
||||
WICHTIG – Längenbegrenzung ist deine Verantwortung:
|
||||
Wenn ein idealer Titel länger als {MAX_TITLE_LENGTH} Zeichen wäre, darfst und musst du ihn selbst kürzen. Optionen:
|
||||
- Betreff verkürzen (z.B. "Steuerbescheid 2024" statt "Einkommensteuerbescheid 2024")
|
||||
- Unwesentliche Details weglassen
|
||||
- Absender mit Standard-Abkürzung darstellen
|
||||
- Absender weglassen und nur Betreff nutzen, falls sinnvoll
|
||||
Liefere IMMER einen Titel, der das Zeichenlimit einhält. Niemals einen, der es überschreitet.
|
||||
|
||||
Beispiele für gute Titel:
|
||||
- Stadtwerke Bochum Grundbesitzabgaben 2025
|
||||
- Allianz Versicherung Kfz-Nachtrag Polo
|
||||
- Finanzamt Bochum Steuerbescheid 2024
|
||||
- KfW Förderbescheid Energieeffizienz
|
||||
|
||||
Beispiele für Kürzung bei Längenlimit:
|
||||
- zu lang: "Versicherungsgesellschaft Allianz Versicherung AG Kfz-Versicherungsnachtrag Volkswagen Polo" → gekürzt: "Allianz Kfz-Nachtrag Polo"
|
||||
- zu lang: "Bundesfinanzbehörde Finanzamt Bochum Bescheid zur Einkommensteuer Veranlagung" → gekürzt: "Finanzamt Bochum Steuerbescheid"
|
||||
|
||||
Datumsermittlung nach Priorität:
|
||||
- Rechnungsdatum
|
||||
- Dokumentdatum
|
||||
@@ -12,11 +38,15 @@ Datumsermittlung nach Priorität:
|
||||
- Schreibdatum oder Ende eines Leistungszeitraums
|
||||
- Kein Datum angeben, wenn kein belastbares Datum eindeutig ableitbar ist
|
||||
|
||||
Titelregeln:
|
||||
- Titel auf Deutsch formulieren
|
||||
- Eigennamen (Personen, Firmen, Orte) unverändert übernehmen
|
||||
- Maximal 20 Zeichen (nur der Basistitel, ohne Datumspräfix)
|
||||
- Keine Sonderzeichen außer Leerzeichen
|
||||
- Eindeutig und verständlich, nicht generisch
|
||||
|
||||
Wenn das Dokument nicht eindeutig interpretierbar ist, beschreibe dies im Reasoning.
|
||||
|
||||
**Ausgabeformat: Ausschließlich reines JSON-Objekt**
|
||||
|
||||
Antworte nur mit einem JSON-Objekt nach folgendem Schema:
|
||||
- Keine Präambel, keine Erklärungen, keine Markdown-Codeblöcke
|
||||
- `title` (erforderlich): Der ermittelte deutsche Titel nach obigem Schema
|
||||
- `reasoning` (erforderlich): Absender und Betreff in je einem Satz begründen
|
||||
- `date` (optional): Das ermittelte Datum im Format YYYY-MM-DD; auslassen, falls kein belastbares Datum ableitbar ist
|
||||
|
||||
Beispiel:
|
||||
{"title":"Stadtwerke Bochum Grundbesitzabgaben 2025","reasoning":"Absender ist Stadtwerke Bochum laut Briefkopf. Betreff ist die Jahresabrechnung der Grundbesitzabgaben 2025.","date":"2025-03-15"}
|
||||
@@ -0,0 +1,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`
|
||||
+41
-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+)
|
||||
@@ -315,3 +315,41 @@ ausdrücklich für spätere Ausbaustufen vorgesehen:
|
||||
**Build:** ERFOLGREICH · 1.398 Tests · 0 Failures · 0 Errors · Laufzeit 01:18 min
|
||||
**Alle 20 Spezifikations-Prüfpunkte:** erfüllt
|
||||
**Dokumentation:** vollständig und konsistent
|
||||
|
||||
---
|
||||
|
||||
# V2.9-Fixes (Stand 2026-04-24)
|
||||
|
||||
Die folgenden Issues wurden nach dem V2.0-Abschluss behoben und sind im aktuellen Stand integriert.
|
||||
|
||||
| Issue | Titel | Status |
|
||||
|---|---|---|
|
||||
| #27 | Mausrad-Seitenwechsel und zuverlässiger Seitenanfang in PDF-Vorschau | **behoben** |
|
||||
| #28 | Anwendung standardmäßig im Vollbild starten | **behoben** |
|
||||
| #29 | Eigenes PDF-Rendering mit PDFBox statt PDFViewFX | **behoben** |
|
||||
| #33 | Letzte Konfigurationsdatei beim Neustart automatisch laden | **behoben** |
|
||||
|
||||
### Beschreibung der Fixes
|
||||
|
||||
**#27 / #29 – PDF-Vorschau-Stabilität und PDFBox-Migration:**
|
||||
Mehrere aufeinanderfolgende Fixes stabilisierten die PDF-Vorschau. Zunächst wurden
|
||||
Scroll-Schutz und zuverlässiger Seitenanfang per ImageView-Listener verbessert.
|
||||
Im letzten Schritt (#29) wurde die externe PDFViewFX-Abhängigkeit vollständig
|
||||
durch direktes Rendering via `PDFRenderer.renderImageWithDPI` (Apache PDFBox, 120 DPI)
|
||||
ersetzt. Lazy Rendering mit In-Memory-Cache und das „latest preview request wins"-Prinzip
|
||||
blieben erhalten.
|
||||
|
||||
**#28 – Vollbild-Start:**
|
||||
`stage.setMaximized(true)` in `PdfUmbenennerGuiApplication.start()` sorgt dafür, dass
|
||||
das Fenster beim Start automatisch maximiert wird.
|
||||
|
||||
**#33 – Letzte Konfiguration automatisch laden:**
|
||||
`GuiConfigurationEditorWorkspace` speichert den Pfad einer erfolgreich geladenen
|
||||
Konfigurationsdatei in `java.util.prefs.Preferences` (Schlüssel `lastConfigPath`).
|
||||
Beim nächsten Start wird diese Datei automatisch geladen, sofern sie noch existiert.
|
||||
Fehlt die Datei, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
||||
|
||||
---
|
||||
|
||||
> **Hinweis:** Für die Ausbaustufen V2.1 bis V2.9 wurden keine separaten Befundlisteneinträge
|
||||
> erstellt. Befunde, Fixes und Verbesserungen dieser Stufen sind in den Gitea-Issues dokumentiert.
|
||||
|
||||
+269
-14
@@ -54,27 +54,54 @@ Windows Server-Betrieb geeignet.
|
||||
Gemappte Netzlaufwerke wie `S:\` oder `H:\` werden ausdrücklich unterstützt. Eine Ablehnung
|
||||
solcher Pfade allein wegen eines dahinterliegenden UNC-Pfads ist unzulässig.
|
||||
|
||||
### Umfang der V2.0-GUI
|
||||
### Startverhalten der GUI
|
||||
|
||||
Die GUI in V2.0 dient ausschließlich als:
|
||||
Die GUI startet **maximiert** (Vollbild). Beim Start wird die zuletzt geladene
|
||||
Konfigurationsdatei automatisch geladen. Der Pfad wird in den Windows-Benutzereinstellungen
|
||||
gespeichert (`java.util.prefs.Preferences`). Existiert die Datei beim nächsten Start nicht
|
||||
mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
||||
|
||||
- **Konfigurationseditor** für die `.properties`-Datei
|
||||
- **Validierungsoberfläche** (automatische und explizite Prüfung des Konfigurationsstands)
|
||||
- **Technische Testoberfläche** (Erreichbarkeit des Providers, Pfade, SQLite-Datei, Prompt-Datei)
|
||||
### Umfang der GUI
|
||||
|
||||
Die GUI enthält in V2.0 **keinen** manuellen Verarbeitungslauf. Das Starten eines Batch-Laufs
|
||||
aus der GUI ist erst ab V2.1+ vorgesehen. Der headless Betrieb über den Windows Task Scheduler
|
||||
bleibt der einzige Weg, PDF-Dateien automatisiert zu verarbeiten.
|
||||
Die GUI enthält drei Tabs:
|
||||
|
||||
- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für
|
||||
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
|
||||
Prompt-Datei).
|
||||
- **Tab „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit
|
||||
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument. Pro Zeile ist eine
|
||||
**integrierte PDF-Vorschau** der Quelldatei sowie ein **editierbarer Dateiname-Bereich**
|
||||
verfügbar. Der Lauf verwendet den zuletzt gespeicherten Stand der `.properties`-Datei;
|
||||
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
|
||||
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
|
||||
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
|
||||
- **Tab „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.
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Java 21 (JRE oder JDK)
|
||||
- Zugang zu einem KI-Dienst (API-Schlüssel erforderlich; unterstützte Provider: OpenAI-kompatibel, Anthropic Claude)
|
||||
- Quellordner mit OCR-verarbeiteten PDF-Dateien
|
||||
- Schreibzugriff auf Zielordner und Datenbankverzeichnis
|
||||
|
||||
### Java-Laufzeitumgebung
|
||||
|
||||
- Bei Verwendung des **Shade-JAR** direkt: **Java 21 JRE** auf dem Zielsystem erforderlich.
|
||||
- Bei Verwendung des **Windows-Installers (V3.0)**: **keine** separate Java-Installation notwendig –
|
||||
die JRE 21 ist in der installierten Anwendung eingebettet.
|
||||
|
||||
---
|
||||
|
||||
## Start des ausführbaren JAR
|
||||
@@ -97,6 +124,28 @@ java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHO
|
||||
Die Anwendung liest die Konfiguration standardmäßig aus `config/application.properties` relativ zum
|
||||
Arbeitsverzeichnis, in dem der Befehl ausgeführt wird.
|
||||
|
||||
### Konsolen-Encoding unter Windows
|
||||
|
||||
Die Anwendung schreibt alle Log-Ausgaben in UTF-8. Windows-Konsolen (PowerShell, CMD) verwenden
|
||||
standardmäßig den OEM-Codepage (z. B. CP850), was zu unlesbaren Sonderzeichen führt.
|
||||
|
||||
**Lösung:** Konsole vor dem Start auf UTF-8 umschalten:
|
||||
|
||||
```
|
||||
chcp 65001
|
||||
java -jar pdf-umbenenner-bootstrap-*.jar --headless
|
||||
```
|
||||
|
||||
Alternativ kann die UTF-8-Ausgabe auch als JVM-Argument angegeben werden (Java 17+):
|
||||
|
||||
```
|
||||
java -Dstdout.encoding=UTF-8 -jar pdf-umbenenner-bootstrap-*.jar --headless
|
||||
```
|
||||
|
||||
> **Hinweis:** Die mitgelieferten Batch-Dateien (`PDF-KI-Renamer.bat`, `PDF-KI-Renamer-GUI.bat`)
|
||||
> rufen `chcp 65001` automatisch auf. Der Windows Task Scheduler schreibt Log-Ausgaben in eine
|
||||
> Protokolldatei, die stets UTF-8-kodiert ist – dort entsteht kein Anzeigeproblem.
|
||||
|
||||
### Start über Windows Task Scheduler
|
||||
|
||||
Empfohlene Startsequenz für den headless Betrieb über den Windows Task Scheduler:
|
||||
@@ -147,6 +196,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–39 und 100–120 erzeugen eine Startwarnung. |
|
||||
| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) |
|
||||
|
||||
### Provider-Parameter
|
||||
@@ -249,6 +299,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
|
||||
@@ -266,7 +345,7 @@ YYYY-MM-DD - Titel(1).pdf
|
||||
YYYY-MM-DD - Titel(2).pdf
|
||||
```
|
||||
|
||||
Das Suffix zählt nicht zu den 20 Zeichen des Basistitels.
|
||||
Das Suffix zählt nicht zur konfigurierten maximalen Titellänge des Basistitels.
|
||||
|
||||
---
|
||||
|
||||
@@ -400,10 +479,144 @@ JavaFX-Klassen sind zwar im Shade-JAR enthalten, werden im headless Pfad jedoch
|
||||
nicht geladen. Headless läuft damit auch auf Windows Server-Systemen ohne
|
||||
JavaFX-fähige Grafiklaufzeit.
|
||||
|
||||
### Keine EXE, kein Installer
|
||||
### Windows-Installer (V3.0)
|
||||
|
||||
In V2.0 wird ausschließlich das JAR als Distributionsartefakt ausgeliefert.
|
||||
EXE-Wrapper und Installer sind bewusst nicht Bestandteil von V2.0.
|
||||
Ab V3.0 steht neben dem Shade-JAR ein vollwertiger **MSI-Installer** für Windows 10/11 (x64)
|
||||
und Windows Server 2022 (x64) bereit. Der Installer enthält eine eingebettete JRE 21 und
|
||||
benötigt keine separate Java-Installation auf dem Zielsystem. Das Shade-JAR bleibt das
|
||||
primäre Distributionsartefakt; der MSI ist eine zusätzliche Option für Systeme ohne
|
||||
Java-Installation und für den Standard-Installationspfad nach `C:\Program Files\`.
|
||||
|
||||
> **Hinweis zur CI-Umgebung:** Der MSI-Build ist Windows-only (`jpackage` + WiX Toolset 3.x).
|
||||
> Jenkins läuft im Linux-Container auf dem Synology NAS und kann kein MSI erzeugen.
|
||||
> Der MSI-Build wird bewusst manuell auf der Windows-Entwicklungsmaschine ausgeführt.
|
||||
|
||||
**Voraussetzungen für den Installer-Build (nur auf der Entwicklungsmaschine):**
|
||||
- Windows x64
|
||||
- JDK 21 im PATH
|
||||
- [WiX Toolset 3.x](https://wixtoolset.org/) im PATH
|
||||
|
||||
**MSI bauen:**
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests
|
||||
```
|
||||
|
||||
Der normale Build (`mvn clean verify`) ist vom Profil `release` vollständig unberührt
|
||||
und benötigt **kein** WiX Toolset.
|
||||
|
||||
Das Ergebnis liegt unter:
|
||||
|
||||
```
|
||||
pdf-umbenenner-packaging/target/dist/
|
||||
PDF-KI-Renamer-2.5.0.msi ← Windows-Installer
|
||||
PDF-KI-Renamer.bat ← Headless-Start (zusätzlich kopiert)
|
||||
PDF-KI-Renamer-GUI.bat ← GUI-Start (zusätzlich kopiert)
|
||||
```
|
||||
|
||||
**Installationsverzeichnis:**
|
||||
|
||||
Der Installer legt die Anwendung nach `C:\Program Files\PDF KI Renamer\` ab.
|
||||
Beide Batch-Dateien landen ebenfalls dort. Der Installer erstellt:
|
||||
- einen Startmenü-Eintrag in der Gruppe `PDF KI Renamer` (startet die GUI)
|
||||
- einen Desktop-Shortcut (startet die GUI)
|
||||
|
||||
Die Deinstallation erfolgt über „Programme und Features" in der Windows-Systemsteuerung.
|
||||
Vom Installer angelegte Dateien werden entfernt; Nutzerdaten unter `C:\ProgramData\PDF KI Renamer\`
|
||||
(Konfiguration, Logs, SQLite-Datenbank) bleiben erhalten.
|
||||
|
||||
**Konfigurationsverzeichnis (`ProgramData`):**
|
||||
|
||||
Das empfohlene Konfigurationsverzeichnis für den produktiven Betrieb ist:
|
||||
|
||||
```
|
||||
C:\ProgramData\PDF KI Renamer\config\
|
||||
```
|
||||
|
||||
Die Anwendung löst dieses Verzeichnis **nicht** automatisch auf. Der Pfad zur
|
||||
Konfigurationsdatei muss weiterhin explizit über `--config` angegeben werden
|
||||
(siehe „CLI-Optionen"). Der Installer legt eine Beispiel-Konfiguration namens
|
||||
`application.example.properties` neben den installierten Artefakten im
|
||||
Installationsverzeichnis ab. **Der Betreiber muss diese Beispieldatei manuell nach**
|
||||
`C:\ProgramData\PDF KI Renamer\config\` **kopieren und anpassen.**
|
||||
|
||||
**Beispielaufruf headless mit installierter Anwendung:**
|
||||
|
||||
```powershell
|
||||
"C:\Program Files\PDF KI Renamer\PDF-KI-Renamer.bat" --config "C:\ProgramData\PDF KI Renamer\config\application.properties"
|
||||
```
|
||||
|
||||
**Hinweis:** Der MSI ist nicht signiert. Beim Installieren erscheint eine
|
||||
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
|
||||
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
|
||||
|
||||
**Empfehlung für Pfade im MSI-Betrieb:**
|
||||
|
||||
Für den MSI-Betrieb (Startmenü, Task Scheduler) müssen alle Dateipfade als **absolute Pfade**
|
||||
konfiguriert werden. Relative Pfade werden relativ zum Installationsverzeichnis
|
||||
`C:\Program Files\PDF KI Renamer\` aufgelöst, das **schreibgeschützt** ist. Dadurch
|
||||
schlagen Schreibversuche (Logs, SQLite-Datenbank, Lock-Datei) ohne Fehlermeldung fehl.
|
||||
|
||||
> **Warnung – Relative Pfade im MSI-Betrieb nicht verwenden:**
|
||||
> Pfade wie `./logs`, `./work/local/logs` oder `logs/` werden im MSI-Betrieb relativ
|
||||
> zum Installationsverzeichnis aufgelöst. Das Installationsverzeichnis ist für normale
|
||||
> Benutzerkonten schreibgeschützt. Log4j2 scheitert dann still, ohne eine sichtbare
|
||||
> Fehlermeldung zu erzeugen.
|
||||
|
||||
> **Warnung – Backslashes in `.properties`-Dateien:**
|
||||
> In Java-`.properties`-Dateien werden Backslashes (`\`) als Escape-Zeichen interpretiert.
|
||||
> Windows-Pfade wie `C:\Users\Funny\Logs` müssen entweder mit Forward-Slashes
|
||||
> (`C:/Users/Funny/Logs`) oder mit doppelten Backslashes (`C:\\Users\\Funny\\Logs`)
|
||||
> angegeben werden. Einfache Backslashes werden stillschweigend falsch interpretiert.
|
||||
|
||||
Betroffene Parameter:
|
||||
|
||||
| Parameter | Empfehlung |
|
||||
|---|---|
|
||||
| `log.directory` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/logs` |
|
||||
| `runtime.lock.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock` |
|
||||
| `prompt.template.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/prompts/template.txt` |
|
||||
| `sqlite.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/pdf-umbenenner.db` |
|
||||
|
||||
Das empfohlene Konfigurationsverzeichnis für alle schreibbaren Daten im MSI-Betrieb ist
|
||||
`C:\ProgramData\PDF KI Renamer\`, da dieses Verzeichnis standardmäßig für alle
|
||||
Benutzerkonten schreibbar ist und bei der Deinstallation erhalten bleibt.
|
||||
|
||||
**Diagnose: Log-Datei-Prüfpunkt in den technischen Tests**
|
||||
|
||||
Die technischen Tests (Schaltfläche „Technische Tests ausführen" im Konfigurationseditor)
|
||||
enthalten einen dedizierten Prüfpunkt **„Log-Verzeichnis beschreibbar"**, der anzeigt:
|
||||
|
||||
- den konfigurierten `log.directory`-Wert (roh und als aufgelöster absoluter Pfad),
|
||||
- ob das Verzeichnis vorhanden und beschreibbar ist,
|
||||
- den tatsächlichen Log-Dateipfad aus der laufenden Log4j2-Konfiguration.
|
||||
|
||||
Ein nicht beschreibbares Log-Verzeichnis wird als **Warnung** angezeigt, nicht als Fehler
|
||||
(die Anwendung kann ohne Datei-Logging laufen). Der Prüfpunkt hilft, den typischen
|
||||
MSI-Betriebsfehler – relatives `log.directory` auf schreibgeschütztem Installationspfad –
|
||||
frühzeitig zu erkennen.
|
||||
|
||||
### MSI-Release-Checkliste
|
||||
|
||||
Die folgende Checkliste ist vor jeder MSI-Auslieferung manuell abzuarbeiten.
|
||||
|
||||
- [ ] Neuinstallation auf sauberer Windows-Umgebung ohne vorinstalliertes Java
|
||||
- [ ] Installation in Installationspfad **mit Leerzeichen** (z. B. `C:\Program Files\PDF KI Renamer\`)
|
||||
- [ ] Upgrade von installiertem Vorgänger-MSI (kein manuelles Deinstallieren)
|
||||
- [ ] GUI-Start über Startmenü-Eintrag
|
||||
- [ ] Headless-Start über `PDF-KI-Renamer.bat` im Windows Task Scheduler
|
||||
- [ ] Desktop-Shortcut vorhanden oder Einschränkung hier dokumentiert
|
||||
- [ ] App-Version `3.0.x` im Windows-Installer sichtbar („Programme und Features")
|
||||
- [ ] Deinstallation sauber – Konfiguration unter `C:\ProgramData\PDF KI Renamer\` bleibt erhalten
|
||||
- [ ] SmartScreen-Warnung erscheint und wird durch „Weitere Informationen → Trotzdem ausführen" bestätigt
|
||||
- [ ] BAT-Dateien funktionieren bei Installationspfad mit Leerzeichen
|
||||
- [ ] Anwendungsstart **ohne Entwicklungs-JDK** erfolgreich: GUI-Start, PDF laden und rendern, Verarbeitungslauf starten, Verlaufs-Tab öffnen (Verifikation der `addModules`-Liste)
|
||||
|
||||
> **Hinweis zur JDK-freien Laufzeit-Verifikation:** Nur ein erfolgreicher Test
|
||||
> auf einem System ohne installiertes JDK bestätigt die Vollständigkeit der
|
||||
> `addModules`-Liste in `pdf-umbenenner-packaging/pom.xml`. Die aktuelle Liste
|
||||
> wurde per `jdeps --print-module-deps --ignore-missing-deps` ermittelt;
|
||||
> vollständige Ausgabe in `pdf-umbenenner-packaging/jdeps-output.txt`.
|
||||
|
||||
### Build-Kommandos
|
||||
|
||||
@@ -447,6 +660,48 @@ Auf Unix-Systemen (headless CI):
|
||||
|
||||
---
|
||||
|
||||
## GUI: Selektive Wiederverarbeitung und Status-Reset
|
||||
|
||||
Die GUI ermöglicht nach Abschluss eines Verarbeitungslaufs zwei zusätzliche Aktionen auf der Ergebnisliste:
|
||||
|
||||
### Selektion in der Ergebnisliste
|
||||
|
||||
Die Ergebnisliste enthält eine **Checkbox pro Zeile** sowie eine **Master-Checkbox** zum Auswählen aller Einträge.
|
||||
- Auswahl erfolgt wie im Windows Explorer mit **Shift/Strg-Mehrfachselektion**
|
||||
- Alle vier Statustypen sind selektierbar: erfolgreich, retryable, permanent fehlgeschlagen, übersprungen
|
||||
- Während eines Laufs ist die Selektion **gesperrt**
|
||||
|
||||
### Button „Erneut verarbeiten"
|
||||
|
||||
**Aktion:** DB-Status zurücksetzen + sofortiger Mini-Lauf nur für ausgewählte Dateien.
|
||||
|
||||
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||
- Der Mini-Lauf arbeitet auf einem Snapshot der beim Klick ausgewählten Einträge
|
||||
- Nicht ausgewählte Einträge bleiben unverändert in der Liste
|
||||
- Verhalten identisch zu regulärem Lauf (gleiche Anwendungslogik, nur eingeschränkte Dateimenge)
|
||||
|
||||
**Besonderheit bei identischem Zieldateinamen:** Verarbeitet der KI-Provider wieder denselben Dateinamen wie ein vorangegangener erfolgreicher Lauf, erhält der Eintrag **Status erfolgreich** – es wird keine erneute Kopie erzeugt, kein Fehler.
|
||||
|
||||
**Fehlende Quelldatei:** Ist die Datei zum Zeitpunkt des Mini-Laufs nicht mehr vorhanden, erhält der Eintrag **Status permanent fehlgeschlagen** mit Meldung „Quelldatei nicht gefunden".
|
||||
|
||||
### Button „Status zurücksetzen"
|
||||
|
||||
**Aktion:** Nur DB-Status zurücksetzen, keine sofortige Verarbeitung.
|
||||
|
||||
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||
- Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt – wartet auf nächsten Lauf"**
|
||||
- Beim nächsten regulären Lauf werden zurückgesetzte Dateien automatisch mitgenommen
|
||||
- **Best-effort-Reset:** Erfolgreiche und fehlgeschlagene Resets werden pro Eintrag einzeln durchgeführt; Zusammenfassung zeigt Erfolge und Fehler
|
||||
|
||||
### Verhalten während eines Mini-Laufs
|
||||
|
||||
- Der **Abbrechen-Button** gilt auch für Mini-Läufe (Soft-Stop)
|
||||
- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt**
|
||||
- Nach Soft-Stop: bereits verarbeitete Einträge behalten neuen Status, noch nicht gestartete zurückgesetzte Einträge warten auf nächsten regulären Lauf
|
||||
- Fortschrittsbalken zeigt Fortschritt für die ausgewählte Dateimenge
|
||||
|
||||
---
|
||||
|
||||
## Weitere Dokumentation
|
||||
|
||||
Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md) beschrieben.
|
||||
@@ -458,7 +713,7 @@ Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md)
|
||||
- Nur OCR-verarbeitete, durchsuchbare PDF-Dateien werden verarbeitet
|
||||
- Keine eingebaute OCR-Funktion
|
||||
- Kein Web-UI, keine REST-API
|
||||
- Die GUI (V2.0) dient der Konfiguration, Validierung und technischen Diagnose – **kein** manueller Verarbeitungslauf aus der GUI
|
||||
- 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`)
|
||||
- Quelldateien werden nie überschrieben, verschoben oder gelöscht
|
||||
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
|
||||
|
||||
@@ -99,8 +99,14 @@ max.pages=10
|
||||
# Werte bis 1000: unkritisch.
|
||||
# Werte 1001-3000: erhoehte KI-Kosten moeglich (Warnung in der GUI).
|
||||
# Werte ab 3001: deutlich erhoehte KI-Kosten moeglich (starke Warnung in der GUI).
|
||||
# Standardvorlage der GUI: 5000.
|
||||
max.text.characters=5000
|
||||
# Standardvorlage der GUI: 1000.
|
||||
max.text.characters=1000
|
||||
|
||||
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
|
||||
# Werte unter 10 oder ueber 120 verhindern den Start.
|
||||
# Werte 10-19: Warnung (fuer die meisten Dokumente nicht empfohlen).
|
||||
# Werte 100-120: Warnung (Dateiname wird sehr lang, Kompatibilitaet mit verschluesselten Volumes pruefen).
|
||||
max.title.length=60
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optionale Parameter
|
||||
|
||||
@@ -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.
|
||||
+606
-25
@@ -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
|
||||
@@ -6,21 +6,23 @@ verwalten und technisch prüfen möchten.
|
||||
|
||||
---
|
||||
|
||||
## 1. Zweck und Scope der GUI in V2.0
|
||||
## 1. Zweck und Scope der GUI
|
||||
|
||||
Die GUI dient in V2.0 ausschließlich als:
|
||||
Die GUI gliedert sich in vier feste Tabs:
|
||||
|
||||
- **Konfigurationseditor** für die `.properties`-Datei
|
||||
- **Validierungsoberfläche** für den aktuellen Konfigurationsstand
|
||||
- **Technische Test- und Diagnoseoberfläche** für Erreichbarkeit des Providers,
|
||||
Pfadprüfungen und Ressourcenverfügbarkeit
|
||||
- **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 „Verlauf"** – Ansicht aller bisher verarbeiteten Dokumente mit Status
|
||||
und Verarbeitungsdetails aus der SQLite-Datenbank (siehe Abschnitt 16).
|
||||
- **Tab 4 „Prompt"** – Editor zum Lesen, Bearbeiten und Speichern der
|
||||
konfigurierten KI-Prompt-Datei (siehe Abschnitt 17).
|
||||
|
||||
Die GUI enthält in V2.0 **keinen** manuellen Verarbeitungslauf. Das Starten eines
|
||||
Batch-Laufs aus der GUI ist erst ab einer späteren Ausbaustufe vorgesehen.
|
||||
Ebenso gibt es keinen Historien-Tab, keine Datenbankansicht und kein Kosten-Tracking.
|
||||
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 18).
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -28,12 +30,17 @@ PDF-Dateien automatisiert zu verarbeiten.
|
||||
|
||||
### 2.1 GUI-Standardstart
|
||||
|
||||
Wird die Anwendung ohne CLI-Argumente gestartet, öffnet sich die JavaFX-Desktop-GUI.
|
||||
Es wird keine Konfigurationsdatei automatisch geladen.
|
||||
Wird die Anwendung ohne CLI-Argumente gestartet, öffnet sich die JavaFX-Desktop-GUI
|
||||
**maximiert** (Vollbild).
|
||||
|
||||
Stattdessen zeigt die GUI einen deutschen Willkommenstext mit dem Hinweis, über
|
||||
„Neu" eine Standardvorlage zu erzeugen oder über „Öffnen" eine bestehende
|
||||
`.properties`-Datei zu laden.
|
||||
Wurde bei einem früheren Start eine Konfigurationsdatei geladen, wird diese automatisch
|
||||
erneut geladen. Der zuletzt verwendete Pfad wird systemseitig gespeichert
|
||||
(`java.util.prefs.Preferences`). Existiert die Datei nicht mehr, startet die GUI ohne
|
||||
Fehlermeldung mit dem Willkommenstext — es erscheint kein Dialog und kein Fehler.
|
||||
|
||||
Beim allerersten Start (oder wenn noch keine Datei geladen wurde) zeigt die GUI einen
|
||||
deutschen Willkommenstext mit dem Hinweis, über „Neu" eine Standardvorlage zu erzeugen
|
||||
oder über „Öffnen" eine bestehende `.properties`-Datei zu laden.
|
||||
|
||||
### 2.2 Start mit `--config <pfad>` (gültige Datei)
|
||||
|
||||
@@ -78,10 +85,9 @@ verworfen werden.
|
||||
|
||||
### 3.2 Zentraler Meldungsbereich
|
||||
|
||||
Am unteren Ende der GUI befindet sich ein großer, nicht editierbarer
|
||||
Meldungsbereich. Er ist dauerhaft sichtbar und zeigt Ergebnisse von
|
||||
Validierungen, technischen Tests, Migrationsmeldungen und sonstige
|
||||
Statusinformationen.
|
||||
Am unteren Ende der GUI befindet sich ein großer Meldungsbereich. Er ist
|
||||
dauerhaft sichtbar und zeigt Ergebnisse von Validierungen, technischen Tests,
|
||||
Migrationsmeldungen und sonstige Statusinformationen.
|
||||
|
||||
Der Meldungsbereich verwendet vier feste Stufen:
|
||||
|
||||
@@ -96,6 +102,39 @@ Nur das Präfix am Zeilenanfang wird farbig dargestellt. Der eigentliche
|
||||
Meldungstext derselben Zeile ist immer schwarz. Die vier Stufen dienen
|
||||
ausschließlich der visuellen Einordnung; sie verhindern das Speichern nicht.
|
||||
|
||||
#### Meldungen kopieren
|
||||
|
||||
Einzelne oder mehrere Meldungen können markiert und in die Zwischenablage
|
||||
kopiert werden:
|
||||
|
||||
- **Einzelne Zeile markieren:** Meldung anklicken
|
||||
- **Mehrere Zeilen markieren:** Shift+Klick (Bereich) oder Strg+Klick (Einzelauswahl)
|
||||
- **Alle Zeilen markieren:** Strg+A
|
||||
- **Markierte Zeilen kopieren:** Strg+C
|
||||
|
||||
Per Rechtsklick steht zusätzlich ein Kontextmenü zur Verfügung:
|
||||
|
||||
| Eintrag | Wirkung |
|
||||
|---------|---------|
|
||||
| **Meldung kopieren** | Kopiert alle markierten Zeilen in die Zwischenablage (nur aktiv, wenn eine Auswahl besteht) |
|
||||
| **Alle Meldungen kopieren** | Kopiert alle aktuell angezeigten Meldungen in die Zwischenablage |
|
||||
|
||||
#### Meldungen leeren
|
||||
|
||||
Unterhalb des Meldungsbereichs befindet sich links der Button **„Meldungen leeren"**.
|
||||
Ein Klick darauf entfernt alle aktuell angezeigten Meldungen sofort und
|
||||
vollständig.
|
||||
|
||||
Darüber hinaus wird der Meldungsbereich in folgenden Situationen automatisch
|
||||
geleert, sodass keine Meldungen aus einem früheren Vorgang sichtbar bleiben:
|
||||
|
||||
| Aktion | Verhalten |
|
||||
|--------|-----------|
|
||||
| **Neu** | Meldungsbereich wird vor der neuen Konfiguration geleert |
|
||||
| **Öffnen** | Meldungsbereich wird vor der geladenen Konfiguration geleert |
|
||||
| **Validieren** | Meldungsbereich wird geleert; danach erscheinen ausschließlich die Befunde des aktuellen Durchlaufs |
|
||||
| **Technische Tests ausführen** | Meldungsbereich wird geleert; danach erscheinen ausschließlich die Befunde des aktuellen Durchlaufs |
|
||||
|
||||
---
|
||||
|
||||
## 4. Aktionen
|
||||
@@ -244,8 +283,24 @@ Wirtschaftliche Warnschwellen für `max.text.characters`:
|
||||
| 1.001 – 3.000 | Warnung |
|
||||
| ab 3.001 | starke Warnung |
|
||||
|
||||
**Standard-Default der GUI-Vorlage:** 1.000 Zeichen (unkritisch)
|
||||
|
||||
`max.pages` wird als Plausibilitäts- und Performance-Hinweis behandelt.
|
||||
|
||||
Validierungsregeln für `max.title.length` (Feld „Max. Titellänge (Zeichen)" im Bereich „Verarbeitungslimits"):
|
||||
|
||||
| Wertebereich | Bewertung |
|
||||
|---|---|
|
||||
| Kein Wert / leer | Fehler – Pflichtfeld, Konfiguration nicht lauffähig |
|
||||
| Keine Ganzzahl (z. B. „abc") | Fehler – ungültiger Typ |
|
||||
| Kleiner als 10 | Fehler – Minimum ist 10 Zeichen |
|
||||
| 10 – 39 | Warnung – Titellänge unter 40 Zeichen – KI-Ergebnisse können unvollständig sein, da Absender allein bereits 15–20 Zeichen benötigt |
|
||||
| 40 – 99 | Normaler Betrieb, keine Meldung |
|
||||
| 100 – 120 | Warnung – Hohe Titellänge – Kompatibilität mit verschlüsselten Volumes prüfen |
|
||||
| Größer als 120 | Fehler – überschreitet sicheres Limit für verschlüsselte Volumes |
|
||||
|
||||
Warnungen verhindern das Speichern nicht. Fehler markieren den Stand als nicht lauffähig; Speichern ist dennoch erlaubt, jedoch erscheint ein deutlicher Hinweis im Meldungsbereich.
|
||||
|
||||
---
|
||||
|
||||
## 7. Provider-Bedienung und Modellabruf
|
||||
@@ -393,13 +448,539 @@ Die GUI wird offiziell nur unter **Windows** unterstützt.
|
||||
|
||||
---
|
||||
|
||||
## 13. Bekannte Einschränkungen V2.0
|
||||
## 13. Tab „Verarbeitungslauf" (live-Verfolgung)
|
||||
|
||||
Der zweite Tab „Verarbeitungslauf" startet einen Batch-Lauf direkt aus der GUI und
|
||||
zeigt dessen Fortschritt in Echtzeit an.
|
||||
|
||||
### Layout
|
||||
- **Fortschrittsbalken** mit Zähler (`n / m Dateien`) im Kopfbereich
|
||||
- **Ergebnisliste** (scrollbar) mit einer Zeile pro abgeschlossener Datei
|
||||
- **Seitenbereich** rechts neben der Liste für die KI-Begründung
|
||||
- **Meldungs- und Zusammenfassungsbereich** unter der Liste
|
||||
- Aktionsknöpfe **Starten** und **Abbrechen**
|
||||
|
||||
### Konfigurationsquelle
|
||||
Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der
|
||||
`.properties`-Datei. Ungespeicherte Änderungen im Konfigurations-Editor fließen **nicht**
|
||||
in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein.
|
||||
|
||||
### Start und Verlauf
|
||||
- Beim Start wird die Dateimenge **einmalig** bestimmt; der Nenner des Fortschrittsbalkens
|
||||
bleibt während des Laufs konstant.
|
||||
- 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 `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 19 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
|
||||
befindliche Datei wird vollständig fertig verarbeitet, anschließend wird der Lauf sauber
|
||||
beendet — keine halbfertigen Zustände in der SQLite-Datenbank.
|
||||
|
||||
### Sperre von Tab 1 während eines Laufs
|
||||
Während eines laufenden Verarbeitungslaufs ist Tab 1 „Konfiguration" gesperrt. Ein
|
||||
sichtbarer Hinweis erinnert daran, dass die Konfiguration während des Laufs nicht
|
||||
editierbar ist. Nach Abschluss, Abbruch oder einer unerwarteten Ausnahme wird Tab 1
|
||||
automatisch wieder freigegeben.
|
||||
|
||||
### Fenster schließen während eines Laufs
|
||||
Versucht der Benutzer das Fenster zu schließen, während ein Lauf aktiv ist, erscheint ein
|
||||
Hinweisdialog mit zwei Optionen:
|
||||
- **Nicht schließen** – der Lauf läuft unverändert weiter
|
||||
- **Lauf beenden und schließen** – ein Soft-Stop wird ausgelöst; nach Abschluss der
|
||||
aktuellen Datei schließt die Anwendung
|
||||
|
||||
### Grenzen und Hinweise
|
||||
- Pro Anwendungsinstanz ist genau **ein** Verarbeitungslauf gleichzeitig zulässig. Ein
|
||||
zweiter Startversuch während eines laufenden Laufs wird mit der Meldung „Ein
|
||||
Verarbeitungslauf ist bereits aktiv." verweigert.
|
||||
- Ein **gleichzeitiger externer headless Lauf** (Windows Task Scheduler) wird weder
|
||||
aktiv erkannt noch technisch geblockt. Der Benutzer ist selbst verantwortlich,
|
||||
parallele Läufe zu vermeiden.
|
||||
- Startet der Lauf mit einem leeren Quellordner, erscheint der Hinweis „Keine
|
||||
verarbeitbaren Dateien im Quellordner gefunden" und die Zusammenfassung
|
||||
`0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen` wird eingetragen.
|
||||
|
||||
---
|
||||
|
||||
## 13a. Selektion, Wiederverarbeitung und Status-Reset (V2.8)
|
||||
|
||||
Nach Abschluss eines Verarbeitungslaufs können einzelne oder mehrere Dateien aus der
|
||||
Ergebnisliste gezielt erneut verarbeitet oder deren Status zurückgesetzt werden.
|
||||
|
||||
### Selektion in der Ergebnisliste
|
||||
|
||||
- Jede Zeile hat eine **Checkbox** am linken Rand
|
||||
- Zusätzlich eine **Master-Checkbox** oberhalb der Liste zum Auswählen/Abwählen aller Einträge
|
||||
- **Zeilenklick** (auf Text/Status-Icon) repräsentiert dieselbe Selektionsmenge wie die Checkbox
|
||||
- **Shift/Strg-Mehrfachselektion** funktioniert wie im Windows Explorer
|
||||
- Shift+Klick: Bereich vom letzten zur aktuellen Zeile
|
||||
- Strg+Klick: einzelne Zeilen hinzufügen/entfernen
|
||||
- Alle vier Statustypen sind selektierbar: ✅ erfolgreich, ⚠️ retryable, ❌ permanent, ⏭️ übersprungen
|
||||
- Die Selektion bleibt nach Aktionen erhalten, bis ein neuer Lauf gestartet wird
|
||||
|
||||
### Button „Erneut verarbeiten"
|
||||
|
||||
**Wann nutzen:** Der KI-Prompt wurde geändert, das Modell gewechselt oder die Verarbeitung einer Datei
|
||||
muss aus anderen Gründen wiederholt werden – und das Ergebnis soll sofort verfügbar sein.
|
||||
|
||||
**Was passiert:**
|
||||
1. Wird ein Button-Klick ausgelöst, wird die aktuelle Selektion als **Snapshot** erfasst
|
||||
2. Der DB-Status aller selektierten Einträge wird zurückgesetzt
|
||||
3. Ein **Mini-Lauf** startet sofort und verarbeitet nur diese Dateien
|
||||
4. Unselektierte Einträge bleiben unverändert in der Liste
|
||||
5. Die Mini-Lauf-Ergebnisse werden live in den selektierten Zeilen aktualisiert
|
||||
|
||||
**Besonderheiten:**
|
||||
- Verarbeitet die KI wieder denselben Dateinamen wie der vorherige erfolgreiche Lauf,
|
||||
erfolgt **keine erneute Kopie** – der Eintrag erhält Status ✅ erfolgreich
|
||||
- Ist die Quelldatei nicht mehr vorhanden, erhält der Eintrag Status ❌ permanent fehlgeschlagen
|
||||
mit Meldung „Quelldatei nicht gefunden"
|
||||
|
||||
**Button-Status:**
|
||||
- **Aktiv:** kein Lauf aktiv UND mindestens 1 Eintrag selektiert
|
||||
- **Inaktiv:** Lauf läuft ODER keine Selektion
|
||||
|
||||
### Button „Status zurücksetzen"
|
||||
|
||||
**Wann nutzen:** Eine Datei soll später erneut verarbeitet werden, aber nicht sofort – z. B. nach
|
||||
Behebung eines externen Fehlers oder planmäßig im nächsten regulären Lauf.
|
||||
|
||||
**Was passiert:**
|
||||
1. Der DB-Status aller selektierten Einträge wird zurückgesetzt
|
||||
2. Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt – wartet auf nächsten Lauf"**
|
||||
3. **Kein sofortiger Mini-Lauf**
|
||||
4. Beim nächsten regulären Lauf werden diese Dateien automatisch mitgenommen
|
||||
|
||||
**Fehlerbehandlung (Best-effort):**
|
||||
- Resets werden pro Eintrag einzeln durchgeführt
|
||||
- Erfolgreiche und fehlgeschlagene Resets werden separat gezählt
|
||||
- Zusammenfassung im Meldungsbereich zeigt:
|
||||
- Anzahl ausgewählter Einträge
|
||||
- Anzahl erfolgreich zurückgesetzt
|
||||
- Anzahl fehlgeschlagen + betroffene Dateinamen
|
||||
|
||||
**Button-Status:**
|
||||
- **Aktiv:** kein Lauf aktiv UND mindestens 1 Eintrag selektiert
|
||||
- **Inaktiv:** Lauf läuft ODER keine Selektion
|
||||
|
||||
### Verhalten während eines Mini-Laufs
|
||||
|
||||
- Der **Abbrechen-Button** löst einen Soft-Stop auch für Mini-Läufe aus:
|
||||
- bereits verarbeitete Einträge behalten ihren neuen Endstatus
|
||||
- noch nicht gestartete, aber bereits zurückgesetzte Einträge erhalten Status
|
||||
„Zurückgesetzt – wartet auf nächsten Lauf" und werden beim nächsten regulären Lauf mitgenommen
|
||||
- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt**
|
||||
- Der **Fortschrittsbalken** zeigt den Fortschritt für die ausgewählte Dateimenge
|
||||
(Nenner = Anzahl selektierter Dateien)
|
||||
- Beide Buttons „Erneut verarbeiten" und „Status zurücksetzen" sind **deaktiviert**
|
||||
|
||||
---
|
||||
|
||||
## 13b. PDF-Vorschau und editierbarer Dateiname im Verarbeitungslauf-Tab
|
||||
|
||||
Nach Abschluss eines Verarbeitungslaufs (oder während laufender Verarbeitung) zeigt
|
||||
ein Klick auf eine Zeile in der Ergebnisliste ein Detail-Panel auf der rechten Seite.
|
||||
Das Panel enthält drei Bereiche:
|
||||
|
||||
### PDF-Vorschau
|
||||
|
||||
- Zeigt die **Quelldatei** der gewählten Zeile als Vorschau an.
|
||||
- **Lazy Rendering:** Seite 1 wird sofort geladen; weitere Seiten werden erst bei
|
||||
Bedarf gerendert.
|
||||
- **In-Memory-Cache:** Bereits gerenderte Seiten werden pro Zeilenselektion
|
||||
zwischengespeichert. Bei einem Zeilenwechsel wird der Cache der vorherigen Auswahl
|
||||
verworfen.
|
||||
- **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.
|
||||
- Das Rendering erfolgt direkt über Apache PDFBox bei 120 DPI.
|
||||
|
||||
### 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 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
|
||||
Dateisystem- und DB-Transaktion inkl. automatischer Rollback bei Fehler.
|
||||
Namenskonflikte im Zielordner werden über ein Dubletten-Suffix aufgelöst.
|
||||
- **Zurücksetzen:** Der Button **„Zurücksetzen"** verwirft die Änderungen und stellt
|
||||
den zuletzt persistierten Dateinamen wieder her.
|
||||
- Wird die Zeile gewechselt oder der Tab verlassen, während ungespeicherte Änderungen
|
||||
vorliegen, erscheint ein Schutzdialog mit den Optionen **„Speichern"**, **„Verwerfen"**
|
||||
und **„Abbrechen"**.
|
||||
- Während eines laufenden Verarbeitungslaufs ist das Dateiname-Feld **gesperrt**.
|
||||
|
||||
---
|
||||
|
||||
## 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. Bekannte Einschränkungen
|
||||
|
||||
| Einschränkung | Erläuterung |
|
||||
|---|---|
|
||||
| Kein manueller Verarbeitungslauf | Das Starten eines Batch-Laufs aus der GUI ist erst ab V2.1+ vorgesehen |
|
||||
| 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 | Läuft gleichzeitig ein headless Batch-Lauf, koordinieren sich GUI und headless Betrieb nicht. Schreibkonflikte können entstehen, wenn dieselbe `.properties`-Datei gleichzeitig über die GUI gespeichert und vom headless Lauf gelesen wird |
|
||||
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
|
||||
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
|
||||
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. Die dauerhaften Ergebnisse sind im Verlauf-Tab (Abschnitt 16) einsehbar. |
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## 15. 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 |
|
||||
|
||||
---
|
||||
|
||||
## 16. 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 19) |
|
||||
| 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"
|
||||
- **„Aktualisieren"** – lädt die Liste neu aus der Datenbank (kein automatisches Echtzeit-Tailing)
|
||||
|
||||
Die Suche erfolgt datenbanksseitig; Sonderzeichen in der Sucheingabe werden korrekt behandelt.
|
||||
|
||||
### 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 |
|
||||
| Provider | Verwendeter KI-Provider |
|
||||
| Modell | Verwendetes Sprachmodell |
|
||||
| Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname |
|
||||
|
||||
**KI-Begründung:** Das `ai_reasoning` des ausgewählten Versuchs als nicht editierbarer Text.
|
||||
|
||||
### Aktionen
|
||||
|
||||
Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung:
|
||||
|
||||
**„Status zurücksetzen"**
|
||||
|
||||
Setzt den Status des ausgewählten Dokuments auf „Wartet auf Verarbeitung" zurück,
|
||||
sodass es beim nächsten Verarbeitungslauf automatisch erneut verarbeitet wird.
|
||||
Die Versuchshistorie bleibt vollständig erhalten – kein Versuch wird gelöscht.
|
||||
Vor der Aktion erscheint ein Bestätigungsdialog.
|
||||
|
||||
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 den Stammsatz und alle Verarbeitungsversuche des ausgewählten Dokuments
|
||||
vollständig aus der Datenbank. Diese Aktion ist **nicht rückgängig zu machen**.
|
||||
Vor der Aktion erscheint ein Bestätigungsdialog mit einem ausdrücklichen Hinweis
|
||||
auf die Unwiderruflichkeit.
|
||||
|
||||
**Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert.
|
||||
Ein Hinweis „Aktion während Verarbeitungslauf nicht möglich." wird angezeigt.
|
||||
|
||||
---
|
||||
|
||||
## 17. 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.
|
||||
|
||||
---
|
||||
|
||||
## 18. 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.
|
||||
|
||||
---
|
||||
|
||||
## 19. 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 16) 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.
|
||||
|
||||
---
|
||||
|
||||
## 20. 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 19)
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# V2.6 – Titellänge parametrisierbar machen
|
||||
|
||||
**Status:** Entwurf
|
||||
**Erstellt:** 2026-04-22
|
||||
**Autor:** Marcus (mit Claude als Mentor)
|
||||
|
||||
---
|
||||
|
||||
## Ziel
|
||||
|
||||
Der maximale Basistitel für KI-generierte PDF-Namen wird nicht mehr hardcodiert,
|
||||
sondern ist über die Konfigurationsdatei steuerbar. Alle bisherigen Magic Numbers
|
||||
(20 und 60 Zeichen) werden durch den konfigurierten Wert ersetzt.
|
||||
|
||||
---
|
||||
|
||||
## Hintergrund
|
||||
|
||||
### Bisheriger Zustand
|
||||
- Titellänge war mit 20 Zeichen im Prompt und 60 Zeichen in der Validierung hardcodiert
|
||||
- Kein zentraler Konfigurationsparameter, Werte über ~20 Dateien verstreut
|
||||
- 60-Zeichen-Limit wurde im Rahmen des Produkttests als pragmatischer Zwischenwert eingeführt
|
||||
|
||||
### Motivation
|
||||
- Verschiedene Einsatzszenarien erfordern unterschiedliche Titellängen
|
||||
- Dateinamenlimits je nach Zielsystem unterschiedlich (siehe Recherche unten)
|
||||
|
||||
### Recherchierte Dateinamenlimits (nur Dateiname, ohne Pfad)
|
||||
|
||||
| System | Limit |
|
||||
|---|---|
|
||||
| Windows 10 / Windows Server 2022 (NTFS) | 255 Zeichen |
|
||||
| Synology NAS – Btrfs (unverschlüsselt) | 255 Zeichen |
|
||||
| Synology NAS – Btrfs (verschlüsselt) | ~143 Zeichen |
|
||||
|
||||
**Hinweis:** Der generierte Dateiname hat das Format `YYYY-MM-DD - <Titel>.pdf`,
|
||||
was bereits 18 Zeichen Overhead bedeutet (Datum + Trennzeichen + Dateiendung).
|
||||
Das sicherste Maximum für verschlüsselte Synology-Volumes ist daher **120 Zeichen**
|
||||
für den Basistitel (143 − 18 = 125, mit Puffer auf 120 gerundet).
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Anforderungen
|
||||
|
||||
### Neuer Konfigurationsparameter
|
||||
|
||||
- **Name:** `ai.title.max.length` (finale Benennung obliegt der Implementierung)
|
||||
- **Typ:** positive Ganzzahl
|
||||
- **Defaultwert:** `60` (bisheriger Wert bleibt erhalten, kein Breaking Change)
|
||||
- **Speicherort:** `.properties`-Konfigurationsdatei
|
||||
|
||||
---
|
||||
|
||||
### Validierungsregeln
|
||||
|
||||
| Wert | Typ | Verhalten |
|
||||
|---|---|---|
|
||||
| Kein Wert / leer | Fehler | Pflichtfeld, Start wird abgebrochen |
|
||||
| Keine Ganzzahl (z. B. „abc", „1.5") | Fehler | Ungültiger Typ, Start wird abgebrochen |
|
||||
| < 1 | Fehler | Wert muss positiv sein, Start wird abgebrochen |
|
||||
| 1–9 | Fehler | Minimum ist 10 Zeichen, Start wird abgebrochen |
|
||||
| 10–39 | Warnung | „Titellänge unter 40 Zeichen – KI-Ergebnisse können unvollständig sein, da Absender allein bereits 15–20 Zeichen benötigt" |
|
||||
| 40–99 | OK | Normaler Betrieb, keine Meldung |
|
||||
| 100–120 | Warnung | „Hohe Titellänge – Kompatibilität mit verschlüsselten Volumes prüfen" |
|
||||
| > 120 | Fehler | Überschreitet sicheres Limit für verschlüsselte Synology-Volumes, Start wird abgebrochen |
|
||||
|
||||
---
|
||||
|
||||
### GUI – Konfigurationseditor
|
||||
|
||||
- Neues Texteingabefeld im Bereich **„Verarbeitungslimits"**
|
||||
- Beschriftung: **„Max. Titellänge (Zeichen)"**
|
||||
- Validierung erfolgt beim Speichern – ungültige Werte werden **nicht** gespeichert
|
||||
- Warnungen und Fehlermeldungen erscheinen im **Meldungsbereich** (unten in der GUI)
|
||||
- Warnungen blockieren das Speichern **nicht**, Fehler hingegen schon
|
||||
|
||||
---
|
||||
|
||||
### Verarbeitung / Backend
|
||||
|
||||
- Alle hardcodierten `20`- und `60`-Zeichen-Limits werden durch den konfigurierten Wert ersetzt
|
||||
- **Keine Magic Numbers** mehr im Produktionscode
|
||||
- Der Wert wird beim Start geladen, validiert und an alle betroffenen Komponenten weitergereicht
|
||||
- Betroffen sind mindestens:
|
||||
- `AiResponseValidator`
|
||||
- `TargetFilenameBuildingService`
|
||||
- Prompt-Template (Hinweistext an die KI)
|
||||
- JavaDoc aller betroffenen Klassen
|
||||
|
||||
---
|
||||
|
||||
### Prompt-Template
|
||||
|
||||
- Der Hinweis auf die Zeichenbegrenzung im Prompt-Template (`config/prompts/template.txt`)
|
||||
wird ebenfalls dynamisch mit dem konfigurierten Wert befüllt
|
||||
- **Hinweis:** Das Prompt-Template liegt außerhalb des JARs und wird zur Laufzeit gelesen.
|
||||
Die Implementierung muss sicherstellen, dass der konfigurierte Wert zur Laufzeit
|
||||
in den Prompt eingesetzt wird (z. B. per Platzhalter-Ersetzung).
|
||||
|
||||
---
|
||||
|
||||
## Nicht in V2.6 enthalten
|
||||
|
||||
- Automatisches Kürzen von zu langen KI-Titeln
|
||||
- Pfadlängen-Validierung (Gesamtpfad inkl. Ordner)
|
||||
- Unterschiedliche Limits je nach Zielsystem (nur ein globaler Wert)
|
||||
|
||||
---
|
||||
|
||||
## Abnahmekriterien
|
||||
|
||||
- [ ] Neuer Parameter ist in der `.properties`-Datei konfigurierbar
|
||||
- [ ] Defaultwert 60 ist abwärtskompatibel (bestehende Configs ohne den Parameter funktionieren)
|
||||
- [ ] Alle Validierungsregeln greifen korrekt (Fehler blockieren Start/Speichern, Warnungen nicht)
|
||||
- [ ] GUI zeigt das neue Feld im richtigen Bereich
|
||||
- [ ] Meldungsbereich zeigt passende Warn- und Fehlertexte
|
||||
- [ ] Keine hardcodierten 20- oder 60-Zeichen-Limits mehr im Produktionscode
|
||||
- [ ] Prompt-Template enthält den konfigurierten Wert zur Laufzeit
|
||||
- [ ] Alle bestehenden Tests werden angepasst
|
||||
- [ ] `mvn clean verify` ist grün
|
||||
@@ -0,0 +1,297 @@
|
||||
# V2.7 – GUI-Verarbeitungslauf mit Live-Verfolgung
|
||||
|
||||
**Status:** Freigegeben
|
||||
**Erstellt:** 2026-04-22
|
||||
**Überarbeitet:** 2026-04-22 (nach Review, finale Version)
|
||||
**Autor:** Marcus (mit Claude als Mentor)
|
||||
|
||||
---
|
||||
|
||||
## Ziel
|
||||
|
||||
V2.7 erweitert die JavaFX-GUI um einen zweiten Tab „Verarbeitungslauf", über den der Benutzer
|
||||
einen Batch-Lauf direkt aus der GUI starten und dessen Fortschritt in Echtzeit verfolgen kann.
|
||||
Der bestehende headless-Betrieb über den Windows Task Scheduler bleibt unverändert erhalten.
|
||||
|
||||
---
|
||||
|
||||
## Hintergrund
|
||||
|
||||
### Bisheriger Zustand
|
||||
- Die GUI dient in V2.0–V2.6 ausschließlich der Konfiguration und technischen Validierung
|
||||
- Ein Verarbeitungslauf kann nur über die Kommandozeile bzw. eine Batch-Datei gestartet werden
|
||||
- Es gibt keine Möglichkeit, den Fortschritt eines laufenden Batches live zu beobachten
|
||||
|
||||
### Motivation
|
||||
- Der manuelle Kommandozeilenstart ist für den Alltagsbetrieb umständlich
|
||||
- Ohne Live-Anzeige ist unklar, ob und wie schnell die Verarbeitung voranschreitet
|
||||
- Eine einzelne Datei wird schnell verarbeitet – eine Gesamtfortschrittsanzeige ist daher
|
||||
sinnvoller als eine dateiweise Einzelanzeige
|
||||
|
||||
---
|
||||
|
||||
## Zielbild
|
||||
|
||||
Nach Abschluss von V2.7 kann der Benutzer:
|
||||
|
||||
1. Im neuen Tab „Verarbeitungslauf" einen Batch-Lauf starten
|
||||
2. Den Gesamtfortschritt über alle Dateien live verfolgen
|
||||
3. Jede abgeschlossene Datei mit Ergebnis in einer Liste sehen
|
||||
4. Das KI-Reasoning zu einer Datei per Klick im Seitenbereich einsehen
|
||||
5. Den laufenden Batch per Soft-Stop sauber abbrechen
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Anforderungen
|
||||
|
||||
### Neuer Tab „Verarbeitungslauf"
|
||||
|
||||
- Der bestehende Tab „Konfiguration" bleibt Tab 1 – unverändert
|
||||
- Tab 2 heißt **„Verarbeitungslauf"**
|
||||
- Tab-Struktur war in V2.0 bereits vorbereitet
|
||||
|
||||
---
|
||||
|
||||
### Layout Tab 2
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [Fortschrittsbalken] 12 / 47 Dateien │
|
||||
├──────────────────────────────────┬──────────────────────┤
|
||||
│ Ergebnisliste │ Seitenbereich │
|
||||
│ (scrollbar) │ (KI-Reasoning) │
|
||||
│ │ │
|
||||
│ │ │
|
||||
├──────────────────────────────────┴──────────────────────┤
|
||||
│ Meldungs- und Zusammenfassungsbereich │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ [Starten] [Abbrechen] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Meldungs- und Zusammenfassungsbereich
|
||||
|
||||
Der untere Bereich des Tab 2 dient als **einheitlicher Meldungs- und Zusammenfassungsbereich**.
|
||||
Er übernimmt zwei Rollen:
|
||||
|
||||
- **Meldungsbereich** – zeigt Startfehler, Hinweise (z. B. 0 Dateien) und technische Exceptions
|
||||
- **Zusammenfassung** – zeigt nach Laufende: `{X} erfolgreich, {X} fehlgeschlagen, {X} übersprungen`
|
||||
|
||||
Während des Laufs ist der Bereich leer oder zeigt den letzten Statushinweis.
|
||||
Es gibt in Tab 2 keinen separaten zweiten Meldungsbereich.
|
||||
|
||||
---
|
||||
|
||||
### Konfigurationsquelle beim Start
|
||||
|
||||
- Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der `.properties`-Datei
|
||||
- Ungespeicherte Änderungen im Konfigurationseditor (Tab 1) fließen **nicht** in den Lauf ein
|
||||
- Der Starten-Button prüft vor dem Lauf, ob die gespeicherte Konfiguration lauffähig ist –
|
||||
nicht den aktuellen Editorzustand
|
||||
|
||||
---
|
||||
|
||||
### Startvoraussetzungen und Startfehler
|
||||
|
||||
Ein Lauf startet nur, wenn alle folgenden Voraussetzungen erfüllt sind:
|
||||
|
||||
| Voraussetzung | Verhalten bei Fehler |
|
||||
|---|---|
|
||||
| Gespeicherte Konfiguration vorhanden und lauffähig | Fehlermeldung, kein Lauf |
|
||||
| Quellordner vorhanden und lesbar | Fehlermeldung, kein Lauf |
|
||||
| Zielordner vorhanden oder anlegbar | Fehlermeldung, kein Lauf |
|
||||
| SQLite-Datei nutzbar | Fehlermeldung, kein Lauf |
|
||||
| API-Key vorhanden | Fehlermeldung, kein Lauf |
|
||||
| Kein anderer Verarbeitungslauf in dieser Anwendungsinstanz aktiv | Fehlermeldung, kein Lauf |
|
||||
|
||||
Bei einem Startfehler:
|
||||
- Erscheint eine klare Fehlermeldung im Meldungs- und Zusammenfassungsbereich
|
||||
- Fortschrittsbalken und Ergebnisliste bleiben unverändert
|
||||
- Starten-Button bleibt aktiv, Abbrechen-Button bleibt deaktiviert
|
||||
|
||||
---
|
||||
|
||||
### Verhalten bei 0 verarbeitbaren Dateien
|
||||
|
||||
- Kein technischer Fehler
|
||||
- Kein Lauf im eigentlichen Sinne
|
||||
- Hinweis im Meldungs- und Zusammenfassungsbereich: „Keine verarbeitbaren Dateien im Quellordner gefunden"
|
||||
- Zusammenfassung: `0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen`
|
||||
|
||||
---
|
||||
|
||||
### Fortschrittsbalken
|
||||
|
||||
- Die zu verarbeitende Dateimenge wird **einmalig beim Start** bestimmt
|
||||
- Der Nenner bleibt für den gesamten Lauf **konstant** – Dateien die während des Laufs
|
||||
im Quellordner auftauchen oder verschwinden, werden nicht berücksichtigt
|
||||
- Gezählt werden **alle abgeschlossenen** Dateien: erfolgreich + fehlgeschlagen + übersprungen
|
||||
- Daneben wird der Zählerstand angezeigt, z. B. „12 / 47 Dateien"
|
||||
- Vor dem ersten Start: leer / 0 %
|
||||
|
||||
---
|
||||
|
||||
### Statusmodell
|
||||
|
||||
Jede Datei erhält nach Abschluss genau einen der folgenden Status:
|
||||
|
||||
| Status | Icon | Bedeutung |
|
||||
|---|---|---|
|
||||
| Erfolgreich | ✅ | Datei wurde umbenannt, Zieldatei erzeugt |
|
||||
| Fehlgeschlagen (retryable) | ⚠️ | Transienter Fehler, wird beim nächsten Lauf erneut versucht |
|
||||
| Fehlgeschlagen (permanent) | ❌ | Inhaltsfehler, kein weiterer Retry |
|
||||
| Übersprungen | ⏭️ | Datei war bereits verarbeitet oder wurde bewusst ausgelassen |
|
||||
|
||||
Alle vier Status zählen als **abgeschlossen** im Sinne des Fortschrittsbalkens.
|
||||
|
||||
---
|
||||
|
||||
### Ergebnisliste
|
||||
|
||||
Jede abgeschlossene Datei erscheint als neue Zeile in der Liste.
|
||||
Nach Abschluss jeder Datei erscheint **ohne manuellen Refresh** ein neuer Eintrag.
|
||||
Die Liste wächst während des Laufs von oben nach unten.
|
||||
|
||||
| Spalte | Erfolg | Fehler / Übersprungen |
|
||||
|---|---|---|
|
||||
| Status-Icon | ✅ / ⚠️ / ❌ / ⏭️ | wie links |
|
||||
| Originaldateiname | Quelldateiname | Quelldateiname |
|
||||
| Neuer Dateiname | Finaler Zieldateiname | `—` |
|
||||
| Datum | Ermitteltes Datum | `—` |
|
||||
| Dauer | Verarbeitungszeit in Sekunden | Verarbeitungszeit in Sekunden |
|
||||
|
||||
- Klick auf eine Zeile zeigt Details im **Seitenbereich**
|
||||
- Die Liste ist scrollbar
|
||||
- Die Liste ist **nicht persistent**: bleibt nur für die Dauer des aktuellen Programmstarts
|
||||
- Bei einem neuen Lauf innerhalb desselben Programmstarts wird die Liste geleert
|
||||
- Nach Programmstart ist die Liste leer
|
||||
|
||||
---
|
||||
|
||||
### Seitenbereich (KI-Reasoning)
|
||||
|
||||
- Rechts neben der Ergebnisliste, fest im Layout verankert (kein Popup, kein Dialog)
|
||||
- Zeigt nach Klick auf eine Zeile:
|
||||
- Originaldateiname
|
||||
- Ermittelter Titel
|
||||
- Ermitteltes Datum
|
||||
- KI-Reasoning (Volltext)
|
||||
- Liegt für einen Eintrag kein KI-Reasoning vor (Fehler vor KI-Aufruf, übersprungen),
|
||||
erscheint der Hinweistext: „Für diesen Eintrag liegt kein KI-Reasoning vor."
|
||||
- Vor dem ersten Klick: Hinweistext „Datei auswählen für Details"
|
||||
- Bei neuem Lauf wird der Seitenbereich geleert
|
||||
|
||||
---
|
||||
|
||||
### Starten-Button
|
||||
|
||||
- Startet den Verarbeitungslauf über alle Dateien im konfigurierten Quellordner
|
||||
- Verwendet die **gespeicherte** Konfiguration – nicht den aktuellen Editorzustand
|
||||
- Gleiches fachliches Batch-Verhalten wie der headless-Betrieb:
|
||||
gleiche Anwendungslogik, gleicher Use Case, nur andere Präsentationsschicht
|
||||
- Keine Dateiauswahl – alle Dateien werden verarbeitet
|
||||
- Während des Laufs: deaktiviert
|
||||
- Nach Abschluss oder Abbruch: wieder aktiv
|
||||
|
||||
---
|
||||
|
||||
### Abbrechen-Button
|
||||
|
||||
- Nur während eines laufenden Batches aktiv, sonst deaktiviert
|
||||
- Verhalten: **Soft-Stop**
|
||||
- Die aktuell in Bearbeitung befindliche Datei wird vollständig fertig verarbeitet
|
||||
- Das Stop-Flag wird nach Abschluss jeder Datei und vor Start der nächsten Datei geprüft –
|
||||
niemals mitten in einer atomaren Persistenzoperation
|
||||
- Danach wird der Lauf sauber beendet, keine halbfertigen Zustände in der SQLite-Datenbank
|
||||
- Nach dem Soft-Stop erscheint die Zusammenfassung im Meldungs- und Zusammenfassungsbereich
|
||||
|
||||
---
|
||||
|
||||
### Konfiguration während des Laufs
|
||||
|
||||
- Tab 1 „Konfiguration" wird während eines laufenden Verarbeitungslaufs **gesperrt**
|
||||
- Im Konfiguration-Tab erscheint ein sichtbarer Hinweis:
|
||||
„Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar"
|
||||
- Nach Abschluss, Abbruch oder unerwarteter Exception wird Tab 1 wieder freigegeben
|
||||
|
||||
---
|
||||
|
||||
### Verhalten bei unerwarteter technischer Exception
|
||||
|
||||
Tritt während des Laufs eine unerwartete Exception auf:
|
||||
|
||||
- Die GUI wechselt in einen definierten terminalen Zustand:
|
||||
- Starten-Button: aktiv
|
||||
- Abbrechen-Button: deaktiviert
|
||||
- Tab 1: entsperrt
|
||||
- Meldungs- und Zusammenfassungsbereich: Fehlermeldung sichtbar
|
||||
- Es entsteht kein „hängender" UI-Zustand
|
||||
|
||||
---
|
||||
|
||||
### Fenster schließen während eines laufenden Laufs
|
||||
|
||||
- Schließt der Benutzer das Fenster während ein Lauf aktiv ist,
|
||||
wird der Close-Request abgefangen
|
||||
- Es erscheint ein Hinweisdialog mit zwei Optionen:
|
||||
- **„Nicht schließen"** – Lauf läuft weiter
|
||||
- **„Lauf beenden und schließen"** – Soft-Stop wird ausgelöst,
|
||||
nach Abschluss der aktuellen Datei schließt die Anwendung
|
||||
- Kein Hard-Abbruch ohne Benutzerentscheidung
|
||||
|
||||
---
|
||||
|
||||
### Parallele Läufe
|
||||
|
||||
- Pro Anwendungsinstanz ist **nur ein Verarbeitungslauf gleichzeitig** zulässig
|
||||
- Ein zweiter Startversuch während ein Lauf aktiv ist wird verweigert mit der Meldung:
|
||||
„Ein Verarbeitungslauf ist bereits aktiv."
|
||||
- **Bekannte Einschränkung:** Ein gleichzeitiger externer headless-Lauf (Windows Task Scheduler)
|
||||
wird von der GUI nicht aktiv erkannt und nicht technisch geblockt.
|
||||
Der Benutzer ist selbst verantwortlich, parallele Läufe zu vermeiden.
|
||||
Diese Einschränkung ist seit V2.0 dokumentiert und bleibt in V2.7 unverändert bestehen.
|
||||
|
||||
---
|
||||
|
||||
## Nicht in V2.7 enthalten
|
||||
|
||||
- Dateiauswahl (welche Dateien verarbeitet werden sollen)
|
||||
- Einzeldatei-Fortschrittsanzeige
|
||||
- Historien-Tab / SQLite-Ansicht
|
||||
- Kosten-Tracking
|
||||
- Automatischer Neustart nach Abschluss
|
||||
- Benachrichtigungen (Windows-Tray, Toast)
|
||||
- Parallelverarbeitung mehrerer Dateien
|
||||
- Technisches Locking gegen externe headless-Läufe
|
||||
|
||||
---
|
||||
|
||||
## Abnahmekriterien
|
||||
|
||||
- [ ] Tab 2 „Verarbeitungslauf" ist in der GUI vorhanden und erreichbar
|
||||
- [ ] Starten-Button verwendet ausschließlich die gespeicherte Konfiguration
|
||||
- [ ] Starten-Button startet den Batch-Lauf über alle Dateien im Quellordner
|
||||
- [ ] Die Dateimenge wird beim Start einmalig bestimmt; der Nenner des Fortschrittsbalkens bleibt während des gesamten Laufs konstant
|
||||
- [ ] Fortschrittsbalken zählt alle abgeschlossenen Dateien (erfolgreich + fehlgeschlagen + übersprungen)
|
||||
- [ ] Nach Abschluss jeder Datei erscheint ohne manuellen Refresh ein neuer Eintrag in der Ergebnisliste
|
||||
- [ ] Alle fünf Spalten der Ergebnisliste sind für Erfolgsfälle korrekt befüllt
|
||||
- [ ] Spalte „Neuer Dateiname" und „Datum" zeigen `—` für Fehler- und Übersprungen-Fälle
|
||||
- [ ] Alle vier Status-Icons sind korrekt: ✅ ⚠️ ❌ ⏭️
|
||||
- [ ] Klick auf Zeile zeigt KI-Reasoning im Seitenbereich
|
||||
- [ ] Einträge ohne KI-Reasoning zeigen den definierten Hinweistext im Seitenbereich
|
||||
- [ ] Seitenbereich zeigt vor erstem Klick den Hinweistext „Datei auswählen für Details"
|
||||
- [ ] Soft-Stop beendet den Lauf nach Abschluss der aktuellen Datei; keine weitere Datei wird begonnen
|
||||
- [ ] Meldungs- und Zusammenfassungsbereich zeigt nach Laufende die Zusammenfassung mit korrekten Zählern
|
||||
- [ ] Tab 1 ist während des Laufs gesperrt, Hinweis ist sichtbar
|
||||
- [ ] Tab 1 wird nach Abschluss, Abbruch oder Exception wieder entsperrt
|
||||
- [ ] Bei unerwarteter Exception wechselt die GUI in den definierten terminalen Zustand
|
||||
- [ ] Ergebnisliste und Seitenbereich sind nach Programmstart leer
|
||||
- [ ] Ergebnisliste und Seitenbereich werden bei neuem Lauf geleert
|
||||
- [ ] Start mit nicht lauffähiger Konfiguration wird verweigert; Fehlermeldung erscheint im Meldungs- und Zusammenfassungsbereich
|
||||
- [ ] Start bei leerem Quellordner erzeugt keinen Fehler; Hinweis erscheint im Meldungs- und Zusammenfassungsbereich
|
||||
- [ ] Zweiter Startversuch während laufendem Lauf wird verweigert; Meldung erscheint
|
||||
- [ ] Close-Request während Lauf öffnet Hinweisdialog mit zwei Optionen
|
||||
- [ ] headless-Betrieb ist unverändert funktionsfähig
|
||||
- [ ] `mvn clean verify` ist grün
|
||||
@@ -0,0 +1,193 @@
|
||||
# V2.8 – Selektive Wiederverarbeitung und Status-Reset in der Ergebnisliste
|
||||
|
||||
**Status:** Freigegeben
|
||||
**Erstellt:** 2026-04-23
|
||||
**Überarbeitet:** 2026-04-23 (nach zwei Reviews, finale Version)
|
||||
**Autor:** Marcus (mit Claude als Mentor)
|
||||
|
||||
---
|
||||
|
||||
## Ziel
|
||||
|
||||
V2.8 erweitert den Tab „Verarbeitungslauf" um die Möglichkeit, einzelne oder mehrere Dateien
|
||||
aus der Ergebnisliste gezielt erneut verarbeiten zu lassen oder deren DB-Status zurückzusetzen –
|
||||
ohne die gesamte Datenbank löschen zu müssen.
|
||||
|
||||
---
|
||||
|
||||
## Hintergrund
|
||||
|
||||
### Bisheriger Zustand
|
||||
- Nach einem abgeschlossenen Lauf sind alle Ergebnisse in der Ergebnisliste sichtbar
|
||||
- Dateien mit Status `FAILED_FINAL` oder `DONE` können nur durch manuelles Löschen der
|
||||
SQLite-Datenbank erneut verarbeitet werden
|
||||
- Es gibt keine Möglichkeit, einzelne Dateien selektiv zurückzusetzen oder neu zu starten
|
||||
|
||||
### Motivation
|
||||
- Nach Anpassung des Prompts oder Wechsel des KI-Modells sollen bereits verarbeitete Dateien
|
||||
erneut verarbeitet werden können – ohne Datenverlust für andere Dokumente
|
||||
- Permanent fehlgeschlagene Dateien sollen nach Behebung der Ursache gezielt neu gestartet
|
||||
werden können
|
||||
- Zwei klar getrennte Aktionen decken unterschiedliche Anwendungsfälle ab:
|
||||
sofortige Wiederverarbeitung vs. Reset für den nächsten regulären Lauf
|
||||
|
||||
---
|
||||
|
||||
## Zielbild
|
||||
|
||||
Nach Abschluss von V2.8 kann der Benutzer:
|
||||
|
||||
1. Eine oder mehrere Dateien in der Ergebnisliste selektieren
|
||||
2. Per „Erneut verarbeiten" einen sofortigen Mini-Lauf nur für die selektierten Dateien starten
|
||||
3. Per „Status zurücksetzen" den DB-Status zurücksetzen ohne sofortige Verarbeitung –
|
||||
die Dateien werden beim nächsten regulären Lauf automatisch mitgenommen
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Anforderungen
|
||||
|
||||
### Selektion in der Ergebnisliste
|
||||
|
||||
- Es gibt genau **eine fachliche Selektion** je Ergebniszeile
|
||||
- Checkbox, Zeilenklick, Shift/Strg und „Alle auswählen" wirken immer auf **dieselbe Selektionsmenge**
|
||||
- Jede Zeile erhält eine **Checkbox** am linken Rand
|
||||
- **Shift/Strg-Mehrfachselektion** wie im Windows Explorer ist möglich
|
||||
- Eine Checkbox **„Alle auswählen"** oberhalb der Liste selektiert/deselektiert alle Einträge
|
||||
- Alle Status sind selektierbar: ✔ erfolgreich, ⚠ retryable, ✘ permanent, ► übersprungen
|
||||
- Die Selektion bleibt erhalten bis ein neuer Lauf gestartet wird
|
||||
- Während eines laufenden Mini-Laufs ist die Selektion **gesperrt** –
|
||||
Änderungen der Selektion nach Laufstart haben keinen Einfluss auf den laufenden Batch
|
||||
|
||||
---
|
||||
|
||||
### Button „Erneut verarbeiten"
|
||||
|
||||
- **Aktion:** DB-Status der selektierten Dateien zurücksetzen + sofortiger Mini-Lauf
|
||||
nur für diese Dateien
|
||||
- **Aktiv wenn:** Kein Lauf aktiv UND mindestens 1 Eintrag selektiert
|
||||
- **Inaktiv wenn:** Lauf läuft ODER keine Selektion
|
||||
- **Verhalten:**
|
||||
- Der Mini-Lauf arbeitet auf einem **Snapshot** der beim Klick selektierten Einträge
|
||||
- DB-Status aller selektierten Einträge wird zurückgesetzt
|
||||
- Sofort danach startet ein Mini-Lauf ausschließlich für diese Dateien
|
||||
- Die Ergebnisliste wird für die selektierten Einträge live aktualisiert
|
||||
- Nicht selektierte Einträge bleiben unverändert in der Liste
|
||||
- Der Mini-Lauf verhält sich fachlich wie ein regulärer Lauf –
|
||||
gleiche Anwendungslogik, gleicher Use Case, nur eingeschränkte Dateimenge
|
||||
|
||||
---
|
||||
|
||||
### Button „Status zurücksetzen"
|
||||
|
||||
- **Aktion:** Nur DB-Status der selektierten Dateien zurücksetzen, keine sofortige Verarbeitung
|
||||
- **Aktiv wenn:** Kein Lauf aktiv UND mindestens 1 Eintrag selektiert
|
||||
- **Inaktiv wenn:** Lauf läuft ODER keine Selektion
|
||||
- **Verhalten:**
|
||||
- DB-Status aller selektierten Einträge wird zurückgesetzt
|
||||
- Kein sofortiger Lauf
|
||||
- Betroffene Zeilen bleiben in der Ergebnisliste sichtbar und erhalten die
|
||||
Kennzeichnung **„Zurückgesetzt – wartet auf nächsten Lauf"**
|
||||
- Beim nächsten regulären Lauf werden die zurückgesetzten Dateien automatisch mitgenommen
|
||||
- **Fehlerbehandlung:** Reset läuft nach **Best-effort**-Prinzip –
|
||||
erfolgreich zurückgesetzte Einträge werden zurückgesetzt, fehlgeschlagene bleiben
|
||||
im alten Status; der Meldungs- und Zusammenfassungsbereich zeigt:
|
||||
- Anzahl ausgewählter Einträge
|
||||
- Anzahl erfolgreich zurückgesetzt
|
||||
- Anzahl fehlgeschlagen
|
||||
- Bei Fehlern: betroffene Dateinamen im Meldungsbereich
|
||||
|
||||
---
|
||||
|
||||
### Welche Status können zurückgesetzt werden
|
||||
|
||||
Alle Status sind zurücksetzbar:
|
||||
|
||||
| UI-Status | DB-Status | Zurücksetzbar | Verhalten im nächsten regulären Lauf |
|
||||
|---|---|---|---|
|
||||
| ✔ Erfolgreich | `DONE` | Ja | Wird erneut verarbeitet |
|
||||
| ⚠ Fehlgeschlagen retryable | `FAILED_RETRYABLE` | Ja | Wird erneut verarbeitet |
|
||||
| ✘ Fehlgeschlagen permanent | `FAILED_FINAL` | Ja | Wird erneut verarbeitet |
|
||||
| ► Übersprungen | `DONE` | Ja | DB-Eintrag `DONE` wird zurückgesetzt, wird erneut verarbeitet |
|
||||
|
||||
---
|
||||
|
||||
### Verhalten bei vorhandener Zieldatei (Re-Run von DONE)
|
||||
|
||||
Wird eine bereits erfolgreich verarbeitete Datei erneut verarbeitet:
|
||||
|
||||
- **KI schlägt identischen Zieldateinamen vor** und Zieldatei ist bereits vorhanden:
|
||||
Datei gilt als **✔ erfolgreich** – kein neuer Eintrag im Zielordner, kein Fehler
|
||||
- **KI schlägt anderen Namen vor:** Normale Verarbeitung –
|
||||
Dubletten-Suffix `(1)`, `(2)` wie im regulären Betrieb wenn nötig
|
||||
|
||||
---
|
||||
|
||||
### Verhalten bei fehlender oder verschobener Quelldatei
|
||||
|
||||
Ist die Quelldatei zum Zeitpunkt des Mini-Laufs nicht mehr vorhanden:
|
||||
|
||||
- Eintrag erhält Status **✘ permanent fehlgeschlagen**
|
||||
- Meldung: „Quelldatei nicht gefunden: {Dateiname}"
|
||||
- Kein weiterer Retry
|
||||
|
||||
---
|
||||
|
||||
### Verhalten während eines Mini-Laufs
|
||||
|
||||
- Der **Abbrechen-Button** (Soft-Stop aus V2.7) gilt auch für den Mini-Lauf
|
||||
- Bei Soft-Stop:
|
||||
- Bereits erfolgreich verarbeitete Einträge behalten ihren neuen Endstatus
|
||||
- Noch nicht gestartete, aber bereits zurückgesetzte Einträge behalten den Status
|
||||
„Zurückgesetzt – wartet auf nächsten Lauf" und werden beim nächsten regulären Lauf mitgenommen
|
||||
- Der Mini-Lauf endet im UI-Zustand „abgebrochen" mit Zusammenfassung
|
||||
- Tab 1 „Konfiguration" wird während des Mini-Laufs gesperrt
|
||||
- Fortschrittsbalken zeigt den Fortschritt des Mini-Laufs –
|
||||
Nenner entspricht der Anzahl der selektierten Dateien
|
||||
- Während eines Mini-Laufs sind „Erneut verarbeiten" und „Status zurücksetzen" deaktiviert
|
||||
- Kein zweiter paralleler Lauf ist startbar
|
||||
|
||||
---
|
||||
|
||||
### Scope dieser Funktion
|
||||
|
||||
Die Funktion gilt ausschließlich für Einträge der **sichtbaren Ergebnisliste der aktuellen Sitzung**.
|
||||
Beim Programmstart erfolgt keine Rekonstruktion der Ergebnisliste aus der DB.
|
||||
|
||||
---
|
||||
|
||||
## Nicht in V2.8 enthalten
|
||||
|
||||
- Historien-Tab / SQLite-Ansicht (V3.0)
|
||||
- Bearbeitung des KI-Titels in der GUI
|
||||
- Manuelles Überschreiben eines Ergebnisses
|
||||
- Massenoperationen außerhalb der Ergebnisliste
|
||||
- Automatischer Re-Run nach Konfigurationsänderung
|
||||
- Rekonstruktion der Ergebnisliste beim Programmstart
|
||||
|
||||
---
|
||||
|
||||
## Abnahmekriterien
|
||||
|
||||
- [ ] Jede Zeile der Ergebnisliste hat eine Checkbox
|
||||
- [ ] Checkbox und Zeilenklick repräsentieren dieselbe Selektionsmenge
|
||||
- [ ] Shift/Strg-Mehrfachselektion funktioniert wie im Windows Explorer
|
||||
- [ ] „Alle auswählen"-Checkbox selektiert/deselektiert alle Einträge
|
||||
- [ ] Alle vier Status sind selektierbar
|
||||
- [ ] Während eines laufenden Mini-Laufs kann die Selektion nicht verändert werden
|
||||
- [ ] Button „Erneut verarbeiten" ist nur aktiv wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||
- [ ] Button „Status zurücksetzen" ist nur aktiv wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||
- [ ] „Erneut verarbeiten" setzt DB-Status zurück und startet sofortigen Mini-Lauf nur für selektierte Dateien
|
||||
- [ ] Der Mini-Lauf verarbeitet genau die beim Start selektierten Einträge – spätere Selektionsänderungen haben keinen Einfluss
|
||||
- [ ] „Status zurücksetzen" setzt nur den DB-Status zurück, betroffene Zeilen erhalten Kennzeichnung „Zurückgesetzt – wartet auf nächsten Lauf"
|
||||
- [ ] Reset-Ergebnis zeigt Anzahl ausgewählter, erfolgreich zurückgesetzter und fehlgeschlagener Einträge
|
||||
- [ ] Bei identischem Zieldateinamen gilt der Eintrag nach Re-Run als ✔ erfolgreich
|
||||
- [ ] Fehlende Quelldatei führt zu ✘ permanent fehlgeschlagen mit Meldung
|
||||
- [ ] Mini-Lauf zeigt korrekten Fortschrittsbalken für die selektierte Dateimenge
|
||||
- [ ] Abbrechen-Button (Soft-Stop) funktioniert auch während eines Mini-Laufs
|
||||
- [ ] Nach Soft-Stop: bereits verarbeitete Einträge behalten neuen Status, nicht gestartete bleiben „Zurückgesetzt"
|
||||
- [ ] Tab 1 ist während des Mini-Laufs gesperrt
|
||||
- [ ] Nicht selektierte Einträge bleiben nach „Erneut verarbeiten" unverändert in der Liste
|
||||
- [ ] Beim nächsten regulären Lauf nach „Status zurücksetzen" werden zurückgesetzte Dateien mitgenommen
|
||||
- [ ] Während eines Mini-Laufs sind beide Buttons deaktiviert
|
||||
- [ ] headless-Betrieb ist unverändert funktionsfähig
|
||||
- [ ] `mvn clean verify` ist grün
|
||||
@@ -0,0 +1,378 @@
|
||||
# V2.9 – Integrierte PDF-Vorschau und Dateinamen-Bearbeitung
|
||||
|
||||
**Status:** Freigegeben
|
||||
**Erstellt:** 2026-04-24
|
||||
**Überarbeitet:** 2026-04-24 (nach zwei ChatGPT-Reviews, finale Version)
|
||||
**Autor:** Marcus (mit Claude als Mentor)
|
||||
|
||||
---
|
||||
|
||||
## Ziel
|
||||
|
||||
V2.9 erweitert den Tab „Verarbeitungslauf" um zwei eng verzahnte Funktionen:
|
||||
|
||||
1. **Integrierte PDF-Vorschau** – beim Anklicken einer Zeile wird die erste Seite der
|
||||
Quelldatei direkt im Detailbereich rechts gerendert (kein separates Fenster, kein
|
||||
zusätzlicher Klick)
|
||||
2. **Editierbarer Dateiname** – der von der KI vorgeschlagene Dateiname kann direkt
|
||||
in der GUI korrigiert werden, bevor er als endgültig gilt
|
||||
|
||||
Beide Funktionen zusammen ermöglichen einen natürlichen Review-Zyklus:
|
||||
**KI benennt → Benutzer schaut rein → Benutzer korrigiert bei Bedarf → fertig.**
|
||||
|
||||
---
|
||||
|
||||
## Hintergrund
|
||||
|
||||
### Bisheriger Zustand
|
||||
|
||||
- Der Detailbereich rechts zeigt nur KI-Begründung als TextArea
|
||||
- Ob der vorgeschlagene Dateiname sinnvoll ist, kann der Benutzer nur anhand des
|
||||
KI-Reasonings beurteilen – den tatsächlichen Dokumentinhalt sieht er nicht
|
||||
- Der generierte Dateiname ist nach dem Lauf nicht mehr veränderbar
|
||||
- Die Spike-Implementierung (PDFViewFX + jai-imageio-jpeg2000 für JBIG2-Unterstützung)
|
||||
hat die technische Machbarkeit bereits bestätigt; der Spike-Code wird im Rahmen
|
||||
von V2.9 durch produktionsreifen Code ersetzt
|
||||
|
||||
### Motivation
|
||||
|
||||
- Benutzer sollen schnell beurteilen können, ob der KI-Dateiname passt,
|
||||
ohne ein externes Programm öffnen zu müssen
|
||||
- Korrekturen sollen direkt in der Anwendung möglich sein – für nicht-technische
|
||||
Benutzer (z. B. Familienmitglieder) ist das eine wesentliche UX-Verbesserung
|
||||
- Die Anwendung wird vom reinen Batch-Prozessor zum assistierten
|
||||
Dokumenten-Review-Werkzeug weiterentwickelt
|
||||
|
||||
---
|
||||
|
||||
## Zielbild
|
||||
|
||||
Nach Abschluss von V2.9 kann der Benutzer:
|
||||
|
||||
1. Eine Zeile in der Ergebnisliste anklicken
|
||||
2. Sofort die erste Seite der zugehörigen **Quelldatei** als Vorschau sehen –
|
||||
ohne weiteren Klick, direkt im Detailbereich
|
||||
3. Weitere Seiten bei Bedarf **auf Anfrage** laden (Lazy Rendering)
|
||||
4. Den vorgeschlagenen Dateinamen **direkt in der GUI bearbeiten** und speichern
|
||||
5. Den headless-Betrieb unverändert nutzen – V2.9 betrifft ausschließlich die GUI
|
||||
|
||||
---
|
||||
|
||||
## Layout-Änderung im Tab „Verarbeitungslauf"
|
||||
|
||||
### Bisheriges Layout
|
||||
|
||||
```
|
||||
[ Fortschrittsbalken ]
|
||||
[ Ergebnistabelle (~75% Breite) | KI-Begründung (~25%) ]
|
||||
[ Buttons ]
|
||||
[ Statuszeile ]
|
||||
```
|
||||
|
||||
### Neues Layout
|
||||
|
||||
```
|
||||
[ Fortschrittsbalken ]
|
||||
[ Ergebnistabelle (~60% Breite) | Detailbereich (~40% Breite) ]
|
||||
[ | KI-Begründung ]
|
||||
[ | Dateiname (editierbar) ]
|
||||
[ | PDF-Vorschau (Seite X/Y) ]
|
||||
[ Buttons ]
|
||||
[ Statuszeile ]
|
||||
```
|
||||
|
||||
- Tabelle und Detailbereich sind durch einen **verschiebbaren Splitter** (SplitPane)
|
||||
getrennt – der Benutzer kann das Verhältnis anpassen
|
||||
- Standard-Split: 60% Tabelle / 40% Detailbereich
|
||||
- Der Detailbereich ist vertikal aufgebaut: KI-Begründung oben (kompakt),
|
||||
darunter Dateiname-Feld, darunter PDF-Vorschau (nimmt verfügbaren Restplatz)
|
||||
- Die PDF-Vorschau rendert die erste Seite **„fit to width"** – Seitenverhältnis wird
|
||||
beibehalten, die Seite füllt die verfügbare Panelbreite aus
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Anforderungen
|
||||
|
||||
### PDF-Vorschau
|
||||
|
||||
#### Grundverhalten
|
||||
|
||||
- Beim Anklicken einer Zeile in der Ergebnisliste wird **automatisch** Seite 1 der
|
||||
zugehörigen **Quelldatei** gerendert und im Vorschaubereich angezeigt
|
||||
- Das Rendering erfolgt **asynchron im Hintergrund** – die GUI bleibt während des
|
||||
Ladens reaktionsfähig
|
||||
- Während des Renderings wird ein **Ladeindikator** (z. B. ProgressIndicator) angezeigt
|
||||
- Die Vorschau zeigt immer die **Quelldatei**, nicht die umbenannte Zieldatei
|
||||
|
||||
#### Lazy Rendering und Seitennavigation
|
||||
|
||||
- Beim ersten Anklicken einer Zeile wird **ausschließlich Seite 1** gerendert
|
||||
- Unterhalb der Vorschau wird die aktuelle Seite sowie die Gesamtseitenzahl
|
||||
angezeigt: „Seite 1 / 12"
|
||||
- Navigation:
|
||||
- Button **„Nächste Seite"** lädt und rendert die jeweils nächste Seite on-demand
|
||||
- Button **„Vorherige Seite"** lädt die vorherige Seite
|
||||
- Bereits gerenderte Seiten werden **gecacht** – ein erneuter Wechsel auf eine
|
||||
bereits gerenderte Seite erfordert kein erneutes Rendering
|
||||
- Der Cache wird geleert wenn eine andere Zeile angeklickt wird
|
||||
- Die Navigations-Buttons sind bei Seite 1 (Zurück) bzw. letzter Seite (Weiter)
|
||||
deaktiviert
|
||||
|
||||
#### Abbruchverhalten bei schnellem Wechsel (Latest Preview Request Wins)
|
||||
|
||||
- Es gilt das Prinzip **„latest preview request wins"**: Wenn während eines laufenden
|
||||
Renderings eine neue Vorschau-Anforderung eingeht – sei es durch Selektionswechsel
|
||||
oder durch Seitennavigation innerhalb derselben PDF – wird das laufende Rendering
|
||||
abgebrochen bzw. sein Ergebnis verworfen
|
||||
- Nur das Ergebnis der zuletzt angeforderten Vorschau darf im Vorschaubereich landen
|
||||
- Veraltete Render-Ergebnisse werden niemals angezeigt
|
||||
|
||||
#### Fehlerfälle PDF-Vorschau
|
||||
|
||||
| Situation | Verhalten |
|
||||
|---|---|
|
||||
| Quelldatei nicht mehr vorhanden | Meldung im Vorschaubereich: „Quelldatei nicht gefunden" |
|
||||
| PDF nicht lesbar / korrupt | Meldung im Vorschaubereich: „PDF konnte nicht geöffnet werden" |
|
||||
| PDF passwortgeschützt / verschlüsselt | Meldung im Vorschaubereich: „PDF ist passwortgeschützt und kann nicht angezeigt werden" |
|
||||
| JBIG2-Bilder nicht vollständig dekodierbar | Seite wird teilweise gerendert; kein Fehler-Abbruch; kein Hinweis nötig |
|
||||
| Kein Eintrag selektiert | Vorschaubereich zeigt neutralen Platzhaltertext |
|
||||
|
||||
#### Technische Grundlage
|
||||
|
||||
- Bibliothek: `com.dlsc.pdfviewfx:pdfviewfx` (bereits im Spike erfolgreich getestet)
|
||||
- Zusatzabhängigkeit für JBIG2 und erweiterte Bildformate:
|
||||
`com.github.jai-imageio:jai-imageio-jpeg2000` (bereits im Spike ergänzt)
|
||||
- Der Spike-Code (`PdfViewerSpike.java`, Spike-Button in `GuiBatchRunTab`) wird
|
||||
vollständig entfernt und durch die produktive Implementierung ersetzt
|
||||
- Rendering läuft in einem dedizierten Background-Thread (nicht im JavaFX
|
||||
Application Thread)
|
||||
|
||||
---
|
||||
|
||||
### Editierbarer Dateiname
|
||||
|
||||
#### Zustandsmodell
|
||||
|
||||
Der Dateiname-Bereich kennt drei klar getrennte Zustände:
|
||||
|
||||
| Zustand | Beschreibung |
|
||||
|---|---|
|
||||
| **KI-Vorschlag** | Der von der KI ursprünglich generierte Name – unveränderlich in der DB gespeichert; dient als Referenz für „Zurücksetzen auf KI-Vorschlag" |
|
||||
| **Letzter gespeicherter Name** | Der zuletzt per „Dateiname übernehmen" bestätigte Name (= aktueller FS- und DB-Stand); ist nach dem Batch-Lauf zunächst identisch mit dem KI-Vorschlag |
|
||||
| **Aktuelle Eingabe** | Der aktuell im Textfeld eingetippte, noch nicht gespeicherte Wert |
|
||||
|
||||
**Anzeige-Regel:** Im Textfeld wird beim Selektieren einer Zeile immer der
|
||||
**letzte gespeicherte Name** angezeigt – nicht der KI-Vorschlag.
|
||||
Wurde noch nie manuell gespeichert, sind beide identisch.
|
||||
|
||||
**Dirty-State-Regel:** Dirty-State besteht wenn die **aktuelle Eingabe** vom
|
||||
**letzten gespeicherten Namen** abweicht. Der KI-Vorschlag ist keine Dirty-Basis.
|
||||
|
||||
#### Anzeige
|
||||
|
||||
- Unterhalb der KI-Begründung und oberhalb der PDF-Vorschau befindet sich ein
|
||||
Bereich „Dateiname"
|
||||
- Der Dateiname wird in einem **editierbaren Textfeld** (TextField) angezeigt
|
||||
- Das Textfeld zeigt den **letzten gespeicherten Namen** ohne Dateierweiterung
|
||||
(`.pdf` wird separat als nicht editierbares Label daneben angezeigt)
|
||||
- Solange kein Eintrag selektiert ist, ist das Textfeld leer und deaktiviert
|
||||
- Wenn das Textfeld vom letzten gespeicherten Namen abweicht (**Dirty State**),
|
||||
wird dies durch eine visuelle Markierung am Textfeld angezeigt (z. B. farbiger Rand)
|
||||
|
||||
#### Tastatur- und Schaltflächen-Verhalten
|
||||
|
||||
| Aktion | Verhalten |
|
||||
|---|---|
|
||||
| **Enter** im Textfeld | Löst „Dateiname übernehmen" aus (sofern Validierung grün) |
|
||||
| **Escape** im Textfeld | Verwirft aktuelle Eingabe; stellt **letzten gespeicherten Namen** wieder her |
|
||||
| **„Dateiname übernehmen"** | Startet die atomare Speicher-Transaktion |
|
||||
| **„Zurücksetzen auf KI-Vorschlag"** | Setzt das Textfeld auf den ursprünglichen KI-Vorschlag zurück (kein Speichern – nur Textfeld-Inhalt) |
|
||||
|
||||
Hinweis: „Zurücksetzen auf KI-Vorschlag" und Escape haben **unterschiedliche Semantik**:
|
||||
Escape = zurück zum letzten gespeicherten Stand; „Zurücksetzen" = zurück zum KI-Ursprung.
|
||||
|
||||
#### Speichern-Transaktion (Alles oder Nichts)
|
||||
|
||||
Das Speichern eines geänderten Dateinamens ist eine **atomare Operation** bestehend
|
||||
aus zwei Persistenzschritten:
|
||||
|
||||
1. Zieldatei im Dateisystem umbenennen
|
||||
2. Eintrag in der SQLite-DB aktualisieren
|
||||
|
||||
**Schlägt Schritt 1 oder 2 fehl, wird die gesamte Aktion abgebrochen:**
|
||||
- Bereits durchgeführte Teilschritte werden zurückgerollt
|
||||
- Dateisystem und DB bleiben im vorherigen Zustand
|
||||
- Eine Fehlermeldung im Statusbereich informiert den Benutzer
|
||||
- Das Textfeld behält den eingegebenen Wert – der Benutzer kann es erneut versuchen
|
||||
|
||||
Nach erfolgreicher Transaktion (Projektionsschritt, nicht Teil der Transaktion):
|
||||
- Tabellenspalte „Neuer Dateiname" wird aktualisiert
|
||||
- Erfolgsmeldung im Statusbereich
|
||||
|
||||
Mögliche Fehlerursachen für Schritt 1: Datei-Lock durch andere Prozesse (Scanner, AV),
|
||||
fehlende Schreibrechte, Read-only-Dateisystem, Netzlaufwerk nicht erreichbar.
|
||||
|
||||
#### Konfliktsemantik bei vorhandenem Zieldateinamen
|
||||
|
||||
Existiert im Zielordner bereits eine Datei mit dem neu eingegebenen Namen,
|
||||
wird anhand des **Fingerprints** (SHA-256 des Dateiinhalts) entschieden:
|
||||
|
||||
| Situation | Verhalten |
|
||||
|---|---|
|
||||
| **Gleicher Fingerprint** | Dateien sind inhaltlich identisch → keine Aktion; Meldung im Statusbereich: „Identische Datei bereits vorhanden – keine Umbenennung nötig"; weder FS noch DB werden geändert |
|
||||
| **Unterschiedlicher Fingerprint** | Warnung im Statusbereich; Dateiname im FS erhält automatisch ein Suffix `(1)`, `(2)` usw.; DB wird mit dem tatsächlichen neuen Namen inkl. Suffix aktualisiert |
|
||||
|
||||
#### Validierung des Dateinamens
|
||||
|
||||
Folgende Prüfungen erfolgen **live während der Eingabe**:
|
||||
|
||||
| Prüfung | Verhalten bei Verletzung |
|
||||
|---|---|
|
||||
| Dateiname ist leer oder nur Leerzeichen | Speichern-Button deaktiviert, Hinweistext unterhalb des Feldes |
|
||||
| Führende oder abschließende Leerzeichen | Speichern-Button deaktiviert, Hinweistext |
|
||||
| Unerlaubte Zeichen (`\ / : * ? " < > \|`) | Speichern-Button deaktiviert, Hinweistext |
|
||||
| Reservierte Windows-Namen (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`) | Speichern-Button deaktiviert, Hinweistext |
|
||||
| Dateiname endet auf Punkt | Speichern-Button deaktiviert, Hinweistext |
|
||||
| Dateiname + Zielpfad + `.pdf` überschreitet 259 Zeichen | Speichern-Button deaktiviert, Hinweistext |
|
||||
|
||||
Die 259-Zeichen-Grenze ist eine **bewusste Produktregel** für maximale
|
||||
Windows-Kompatibilität (Windows MAX_PATH = 260 Zeichen inkl. Null-Terminator).
|
||||
|
||||
#### Zustände des Dateiname-Bereichs
|
||||
|
||||
| Zeilenstatus | Verhalten |
|
||||
|---|---|
|
||||
| Kein Eintrag selektiert | Textfeld leer, deaktiviert |
|
||||
| Eintrag mit Status `DONE` (erfolgreich) | Textfeld editierbar, letzter gespeicherter Name vorausgefüllt |
|
||||
| Eintrag mit Status `FAILED_*` | Textfeld leer, deaktiviert (kein Dateiname vorhanden) |
|
||||
| Eintrag mit Status `SKIPPED` | Textfeld deaktiviert |
|
||||
| Lauf aktiv | Textfeld deaktiviert, alle Buttons deaktiviert |
|
||||
|
||||
#### Verhalten bei fehlender Zieldatei
|
||||
|
||||
Ist die Zieldatei zum Zeitpunkt des Speicherns nicht mehr im Zielordner vorhanden:
|
||||
|
||||
- Schritt 1 der Transaktion schlägt fehl
|
||||
- Gemäß Alles-oder-Nichts-Prinzip: DB wird **nicht** aktualisiert
|
||||
- Fehlermeldung im Statusbereich: „Zieldatei nicht gefunden – Umbenennung nicht möglich"
|
||||
- Das Textfeld behält den eingegebenen Wert
|
||||
|
||||
#### Verhalten bei ungespeicherten Änderungen (Dirty State)
|
||||
|
||||
Ein Hinweisdialog erscheint, wenn der Benutzer mit aktivem Dirty-State eine der
|
||||
folgenden Aktionen ausführt:
|
||||
|
||||
- Eine andere Zeile in der Ergebnistabelle anklicken
|
||||
- Den Tab wechseln (Konfiguration ↔ Verarbeitungslauf)
|
||||
- Die Anwendung schließen
|
||||
- Einen neuen Lauf starten
|
||||
|
||||
Dialog-Text: „Der Dateiname wurde geändert aber nicht gespeichert. Änderungen verwerfen?"
|
||||
Optionen: **„Verwerfen"** (Dirty State wird geleert, Aktion wird fortgesetzt) /
|
||||
**„Zurück"** (Dialog schließt, Benutzer bleibt im Textfeld)
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
### Manuelle Namenskorrektur als Application-Use-Case
|
||||
|
||||
Die manuelle Dateinamen-Korrektur wird als **eigenständiger Application-Use-Case**
|
||||
modelliert, nicht im GUI-Adapter implementiert:
|
||||
|
||||
- Ein neuer Use-Case `ManualFileRenameUseCase` (o. ä.) kapselt die atomare Transaktion
|
||||
aus FS-Rename + DB-Update
|
||||
- Der `GuiBatchRunCoordinator` (GUI-Adapter) delegiert ausschließlich an diesen Use-Case
|
||||
- Dateisystem- und DB-Zugriffe laufen ausschließlich über bestehende oder neue
|
||||
Ports/Adapter – kein Direktzugriff aus dem GUI-Adapter
|
||||
- Damit bleibt die hexagonale Architektur gewahrt und der Use-Case ist unabhängig
|
||||
von der GUI testbar
|
||||
|
||||
### Komponenten-Übersicht
|
||||
|
||||
| Komponente | Änderung |
|
||||
|---|---|
|
||||
| `GuiBatchRunTab` | Hauptumbau: SplitPane, Detailbereich-Redesign, Spike-Code entfernen |
|
||||
| `GuiBatchRunResultRow` | Neues Feld: `correctedFileName` als `Optional<String>` |
|
||||
| `GuiBatchRunCoordinator` | Delegiert Dateinamen-Korrektur an neuen Use-Case |
|
||||
| `ManualFileRenameUseCase` | Neuer Application-Use-Case: atomares FS-Rename + DB-Update |
|
||||
| `pom.xml` (GUI-Modul) | PDFViewFX + jai-imageio-jpeg2000 bleiben; Spike-Klasse entfernen |
|
||||
| Domain / Ports | Ggf. neuer Port für Datei-Rename-Operation erforderlich |
|
||||
| Headless-Betrieb | Unberührt |
|
||||
|
||||
---
|
||||
|
||||
## Abhängigkeiten zwischen den Funktionen
|
||||
|
||||
- PDF-Vorschau und editierbarer Dateiname sind **unabhängig voneinander nutzbar**
|
||||
- Beide beziehen sich auf den in der Ergebnistabelle selektierten Eintrag
|
||||
- Beim Selektionswechsel mit Dirty-State: Hinweisdialog erscheint (siehe oben)
|
||||
- PDF-Vorschau-Cache wird beim Selektionswechsel geleert
|
||||
|
||||
---
|
||||
|
||||
## Verhalten während eines laufenden Batch-Laufs
|
||||
|
||||
- Der Detailbereich (PDF-Vorschau + Dateinamen-Editor) ist **vollständig deaktiviert**
|
||||
während ein regulärer Lauf oder Mini-Lauf aktiv ist
|
||||
- Bereits angezeigte Vorschau bleibt sichtbar, aber Navigation und Bearbeitung
|
||||
sind gesperrt
|
||||
|
||||
---
|
||||
|
||||
## Nicht in V2.9 enthalten
|
||||
|
||||
- Löschen der Quelldatei nach Bestätigung (spätere Version)
|
||||
- Vollständiger PDF-Viewer mit freiem Scrollen und Zoom (Issue #23: DPI-Optimierung)
|
||||
- Historien-Tab / SQLite-Ansicht (Issue #7, V3.0)
|
||||
- Automatischer Scheduler / System-Tray (Issues #20, #22)
|
||||
- Kompakteres Layout der Konfigurationsseite (Issue #24)
|
||||
- Anwendungs-Icon (Issue #21)
|
||||
|
||||
---
|
||||
|
||||
## Abnahmekriterien
|
||||
|
||||
### Fachliche Akzeptanz
|
||||
|
||||
#### PDF-Vorschau
|
||||
- [ ] Beim Anklicken einer Zeile wird Seite 1 der Quelldatei automatisch gerendert – ohne extra Klick
|
||||
- [ ] Während des Renderings ist ein Ladeindikator sichtbar; die GUI bleibt reaktionsfähig
|
||||
- [ ] Die Vorschau rendert „fit to width" mit beibehaltenem Seitenverhältnis
|
||||
- [ ] Seitenanzahl wird angezeigt: „Seite 1 / X"
|
||||
- [ ] „Nächste Seite" / „Vorherige Seite" laden Seiten on-demand
|
||||
- [ ] Bereits gerenderte Seiten werden gecacht; Selektionswechsel leert den Cache
|
||||
- [ ] Navigations-Buttons sind korrekt deaktiviert (erste / letzte Seite)
|
||||
- [ ] Schneller Selektionswechsel oder Seitenwechsel während Rendering: nur das zuletzt angeforderte Ergebnis wird angezeigt (latest preview request wins)
|
||||
- [ ] Quelldatei nicht vorhanden → verständliche Fehlermeldung im Vorschaubereich
|
||||
- [ ] PDF nicht lesbar / korrupt → verständliche Fehlermeldung im Vorschaubereich
|
||||
- [ ] PDF passwortgeschützt → verständliche Fehlermeldung im Vorschaubereich
|
||||
|
||||
#### Dateiname-Editor
|
||||
- [ ] Textfeld zeigt beim Selektieren den **letzten gespeicherten Namen** (nicht KI-Vorschlag) ohne `.pdf`-Erweiterung; `.pdf` als nicht editierbares Label daneben sichtbar
|
||||
- [ ] Dateiname ist direkt im Textfeld editierbar
|
||||
- [ ] Dirty-State (Abweichung von letztem gespeichertem Namen) wird visuell am Textfeld angezeigt
|
||||
- [ ] Enter im Textfeld löst „Dateiname übernehmen" aus (wenn Validierung grün)
|
||||
- [ ] Escape im Textfeld stellt den **letzten gespeicherten Namen** wieder her
|
||||
- [ ] „Zurücksetzen auf KI-Vorschlag" setzt das Textfeld auf den KI-Ursprung zurück (ohne Speichern)
|
||||
- [ ] Validierung prüft live: leer/nur Leerzeichen, führende/abschließende Leerzeichen, unerlaubte Zeichen, reservierte Windows-Namen, endet auf Punkt, Pfadlänge > 259
|
||||
- [ ] Bei Validierungsfehler: Speichern-Button deaktiviert, Hinweistext sichtbar
|
||||
- [ ] „Dateiname übernehmen" ist atomar: FS und DB werden beide aktualisiert oder nichts davon
|
||||
- [ ] Bei Fehler in FS oder DB: kein Teilupdate, Rollback, Fehlermeldung im Statusbereich, Textfeld behält Eingabe
|
||||
- [ ] Nach Erfolg: Tabellenspalte und Statusbereich aktualisiert (Projektionsschritt)
|
||||
- [ ] Dateikonflikt mit gleichem Fingerprint → keine Aktion, Meldung „Identische Datei bereits vorhanden"
|
||||
- [ ] Dateikonflikt mit unterschiedlichem Fingerprint → Warnung, Suffix `(1)` usw., DB mit tatsächlichem Namen
|
||||
- [ ] Zieldatei fehlt → Fehlermeldung, weder FS noch DB werden geändert
|
||||
- [ ] Ungespeicherte Änderungen bei Selektionswechsel → Hinweisdialog erscheint
|
||||
- [ ] Ungespeicherte Änderungen bei Tabwechsel → Hinweisdialog erscheint
|
||||
- [ ] Ungespeicherte Änderungen beim App-Schließen → Hinweisdialog erscheint
|
||||
- [ ] Ungespeicherte Änderungen bei Laufstart → Hinweisdialog erscheint
|
||||
- [ ] Status `FAILED_*` und `SKIPPED` → Dateiname-Textfeld deaktiviert
|
||||
- [ ] Während eines aktiven Laufs: Detailbereich vollständig deaktiviert
|
||||
|
||||
### Technische DoD
|
||||
- [ ] Spike-Button und `PdfViewerSpike.java` sind vollständig entfernt
|
||||
- [ ] Tab „Verarbeitungslauf" zeigt Tabelle und Detailbereich nebeneinander (SplitPane, 60/40, verschiebbar)
|
||||
- [ ] `ManualFileRenameUseCase` ist im Application-Modul implementiert und unabhängig von der GUI testbar
|
||||
- [ ] headless-Betrieb ist unverändert funktionsfähig
|
||||
- [ ] `mvn clean verify` ist grün
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,9 +66,9 @@ Fallback auf aktuelles Datum ist erlaubt, wenn kein belastbares Datum eindeutig
|
||||
|
||||
### 4.3 Titel
|
||||
|
||||
- maximal **20 Zeichen (Basistitel)**
|
||||
- maximal **konfigurierbare Anzahl Zeichen (Basistitel, Default 60, gültiger Bereich 10..120)**
|
||||
- verständlich und eindeutig
|
||||
- keine Sonderzeichen außer Leerzeichen
|
||||
- keine Sonderzeichen außer Leerzeichen, Bindestrichen, Punkten, Kommas und Ampersands
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +87,7 @@ Bei Namenskonflikten:
|
||||
|
||||
Regel:
|
||||
|
||||
- 20 Zeichen gelten nur für den Basistitel
|
||||
- die konfigurierte maximale Titellänge gilt nur für den Basistitel
|
||||
- Suffix wird zusätzlich ergänzt
|
||||
|
||||
---
|
||||
@@ -192,7 +192,7 @@ Ein Ergebnis ist korrekt, wenn:
|
||||
|
||||
- Format stimmt
|
||||
- Datum korrekt ist
|
||||
- Titel max. 20 Zeichen hat
|
||||
- Titel die konfigurierte maximale Länge einhält
|
||||
- Dubletten korrekt behandelt wurden
|
||||
- Begründung vorhanden ist
|
||||
- Ergebnis reproduzierbar ist
|
||||
@@ -201,12 +201,31 @@ Ein Ergebnis ist korrekt, wenn:
|
||||
|
||||
## 14. Nicht-Ziele
|
||||
|
||||
- keine manuelle Nachbearbeitung
|
||||
- keine Benutzerinteraktion
|
||||
- kein manueller Verarbeitungslauf durch den Benutzer (die KI-Verarbeitungskette
|
||||
läuft ausschließlich automatisiert)
|
||||
- keine Inhaltsänderung von Dokumenten
|
||||
|
||||
---
|
||||
|
||||
## 14a. Manuelle Korrektur des Dateinamens nach automatischer Verarbeitung
|
||||
|
||||
Nach Abschluss eines automatisierten Verarbeitungslaufs kann der Benutzer den von der
|
||||
KI vorgeschlagenen Dateinamen der Zieldatei **manuell korrigieren**.
|
||||
|
||||
Verbindliche Regeln:
|
||||
|
||||
- Die Korrektur ist **optional** und ersetzt keinen erneuten KI-Aufruf.
|
||||
- Der geänderte Dateiname muss denselben Formatregeln genügen wie ein automatisch
|
||||
erzeugter Name (`YYYY-MM-DD - Titel.pdf`, zulässige Sonderzeichen, Titellänge).
|
||||
- Namenskonflikte im Zielordner werden durch Dubletten-Suffix aufgelöst
|
||||
(analog zur automatischen Verarbeitung).
|
||||
- Die Umbenennung ist **atomar**: entweder Dateisystem und Datenbank werden
|
||||
konsistent aktualisiert, oder die Aktion wird vollständig zurückgerollt.
|
||||
- Die Quelldatei bleibt unverändert.
|
||||
- Ein manuell korrigierter Dateiname wird in der Versuchshistorie persistiert.
|
||||
|
||||
---
|
||||
|
||||
## 15. Qualitätsanforderungen
|
||||
|
||||
- deterministisches Verhalten
|
||||
|
||||
@@ -55,8 +55,8 @@ YYYY-MM-DD - Titel(2).pdf
|
||||
```
|
||||
|
||||
Dabei gilt:
|
||||
- die **20 Zeichen** beziehen sich nur auf den **Basistitel**
|
||||
- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen
|
||||
- die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel**
|
||||
- das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge
|
||||
- die Quelldatei wird **nie** überschrieben oder verändert
|
||||
|
||||
---
|
||||
@@ -133,8 +133,8 @@ Beispiel:
|
||||
|
||||
#### Adapter Out
|
||||
Enthält technische Implementierungen der Outbound-Ports, insbesondere:
|
||||
- Dateisystem
|
||||
- PDFBox
|
||||
- Dateisystem (inkl. `FilesystemTargetFileRenameAdapter` für atomare Zieldatei-Umbenennung)
|
||||
- PDFBox (Textauslese sowie direktes Seitenrendering für die GUI-Vorschau via `PDFRenderer.renderImageWithDPI`)
|
||||
- SQLite
|
||||
- KI-HTTP-Clients (eine Implementierung je unterstütztem Provider, siehe Abschnitt 11)
|
||||
- Properties-/Umgebungs-Konfiguration
|
||||
@@ -204,12 +204,19 @@ Verbindlich zweckmäßige Outbound-Ports:
|
||||
- `FingerprintPort`
|
||||
- `ProcessedDocumentRepository`
|
||||
- `AiNamingPort`
|
||||
- `TargetFileRenamePort`
|
||||
- `ConfigurationPort`
|
||||
- `RunLockPort`
|
||||
- `ClockPort`
|
||||
|
||||
Der `AiNamingPort` bleibt **provider-neutral**. Er kennt weder OpenAI- noch Anthropic-spezifische Typen, Header, URLs oder Antwortformate. Provider-spezifische Details (Endpunkt, Authentifizierung, Request-/Response-Format) leben ausschließlich in den jeweiligen Adapter-Out-Implementierungen.
|
||||
|
||||
Der `TargetFileRenamePort` kapselt die atomare Umbenennung einer bereits kopierten Zieldatei.
|
||||
Er wird vom Use Case `ManualFileRenameUseCase` genutzt und ist durch
|
||||
`FilesystemTargetFileRenameAdapter` implementiert. Der Port-Vertrag enthält keine
|
||||
`Path`- oder NIO-Typen in öffentlichen Signaturen; er arbeitet ausschließlich mit
|
||||
Domain-Typen und String-basierten Dateinamen.
|
||||
|
||||
### 6.3 Logging
|
||||
Logging ist **kein fachlicher Port**. Logging ist technische Infrastruktur.
|
||||
|
||||
@@ -290,7 +297,7 @@ Der Titel muss technisch diese Regeln erfüllen:
|
||||
- Deutsch
|
||||
- verständlich
|
||||
- eindeutig genug für den Dokumentkontext
|
||||
- maximal **20 Zeichen** als Basistitel
|
||||
- maximal die **konfigurierte Titellänge** als Basistitel (Default 60, gültiger Bereich 10..120)
|
||||
- keine unzulässigen Windows-Dateinamenzeichen
|
||||
- keine generischen Platzhalter wie z. B. `Dokument`, `Datei`, `Scan`, `PDF`
|
||||
- Eigennamen bleiben unverändert
|
||||
@@ -532,6 +539,7 @@ Verbindlich zweckmäßige Parameter:
|
||||
- `max.retries.transient`
|
||||
- `max.pages`
|
||||
- `max.text.characters`
|
||||
- `max.title.length`
|
||||
- `prompt.template.file`
|
||||
|
||||
Pro unterstützter Provider-Familie existiert ein eigener Parameter-Namensraum mit zweckmäßig mindestens:
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
# M14 - Arbeitspakete
|
||||
|
||||
## Geltungsbereich
|
||||
|
||||
Dieses Dokument beschreibt ausschließlich die Arbeitspakete für den definierten Meilenstein
|
||||
**M14 – Windows-EXE-Packaging (V2.5)**.
|
||||
|
||||
Der dokumentierte und freigegebene Stand **V2.0** (Commit `1bb7a427357c73039c09a8e1bfe351dee54df765`)
|
||||
wird als vollständig umgesetzt und freigegeben vorausgesetzt.
|
||||
|
||||
Die Arbeitspakete sind bewusst so geschnitten, dass:
|
||||
|
||||
- **KI 1** daraus je Arbeitspaket einen klaren Einzel-Prompt ableiten kann,
|
||||
- **KI 2** genau dieses eine Arbeitspaket in **einem Durchgang** vollständig umsetzen kann,
|
||||
- nach **jedem** Arbeitspaket wieder ein **fehlerfreier, buildbarer Stand** vorliegt.
|
||||
|
||||
Die Reihenfolge der Arbeitspakete ist verbindlich.
|
||||
|
||||
---
|
||||
|
||||
## Zielbild von M14
|
||||
|
||||
Nach Abschluss von M14 existiert neben dem bestehenden Shade-JAR ein zweites
|
||||
Distributionsartefakt: eine **native Windows-EXE**, die alle notwendigen Laufzeitkomponenten
|
||||
enthält und auf einem frischen Windows 10 (x64) oder Windows Server 2022 (x64) ohne
|
||||
vorinstalliertes Java oder sonstige Laufzeitumgebungen ausführbar ist.
|
||||
|
||||
Die EXE wird ausschließlich **lokal auf der Windows-Entwicklungsmaschine** gebaut,
|
||||
gesteuert über das Maven-Profil `-P release`. Jenkins bleibt für den normalen
|
||||
JAR-Build zuständig und ist von M14 nicht betroffen.
|
||||
|
||||
---
|
||||
|
||||
## Abgrenzungen
|
||||
|
||||
### Explizit nicht Bestandteil von M14
|
||||
|
||||
- Windows-Installer (MSI, NSIS, Inno Setup o. Ä.) → V3.0
|
||||
- Code-Signing der EXE → kein kostenfreier Weg für Deutschland verfügbar
|
||||
- Cross-Compilation für andere Betriebssysteme
|
||||
- Änderungen an fachlicher Benennungslogik, Statussemantik, Retry-Regeln oder Persistenz
|
||||
- Änderungen an der GUI oder am headless Batch-Betrieb
|
||||
- Neue Tests für die EXE (manueller Smoke-Test durch den Entwickler)
|
||||
- Jenkins-Integration des EXE-Builds
|
||||
|
||||
### Unveränderte Leitplanken
|
||||
|
||||
- Java 21
|
||||
- Maven Multi-Module
|
||||
- Hexagonale Architektur bleibt unberührt
|
||||
- Das Shade-JAR bleibt das primäre Distributionsartefakt (Änderung in `betrieb.md` erforderlich)
|
||||
- Der normale Build (`mvn verify`) bleibt unverändert und erfordert kein WiX Toolset
|
||||
|
||||
---
|
||||
|
||||
## Verbindliche M14-Regeln für alle Arbeitspakete
|
||||
|
||||
### 1. Neues Maven-Modul
|
||||
|
||||
Das EXE-Packaging wird in einem eigenen Modul `pdf-umbenenner-packaging` gekapselt.
|
||||
Dieses Modul hat genau eine Abhängigkeit: `pdf-umbenenner-bootstrap`.
|
||||
|
||||
### 2. Maven-Profil `release`
|
||||
|
||||
Das Profil `release` aktiviert ausschließlich den EXE-Build via `jpackage`.
|
||||
Der normale Build (`mvn clean verify`) bleibt vom Profil vollständig unberührt.
|
||||
WiX Toolset wird nur im Profil `release` benötigt.
|
||||
|
||||
### 3. Keine Modifikation bestehender Module
|
||||
|
||||
Bestehende Module (`domain`, `application`, `adapter-in-cli`, `adapter-in-gui`,
|
||||
`adapter-out`, `bootstrap`) werden in M14 **nicht** verändert – weder POM noch
|
||||
Produktions- noch Testcode.
|
||||
|
||||
### 4. Batch-Dateien
|
||||
|
||||
Die zwei Batch-Dateien landen als Ressourcen im Modul `pdf-umbenenner-packaging`
|
||||
und werden durch das `jpackage`-Plugin in das EXE-Ausgabeverzeichnis kopiert.
|
||||
|
||||
| Dateiname | Funktion |
|
||||
|---|---|
|
||||
| `PDF-KI-Renamer.bat` | Headless-Modus (`--headless`) |
|
||||
| `PDF-KI-Renamer-GUI.bat` | GUI-Modus (kein Argument) |
|
||||
|
||||
### 5. Dokumentation
|
||||
|
||||
`betrieb.md` wird am Ende von M14 aktualisiert: Der Abschnitt „Keine EXE, kein Installer"
|
||||
wird durch eine korrekte Beschreibung des V2.5-Distributionsartefakts ersetzt.
|
||||
|
||||
---
|
||||
|
||||
## AP-001 Neues Maven-Modul `pdf-umbenenner-packaging` anlegen
|
||||
|
||||
### Voraussetzung
|
||||
Kein. Dieses Arbeitspaket ist der M14-Startpunkt.
|
||||
|
||||
### Ziel
|
||||
Die Projektstruktur wird um das Packaging-Modul erweitert, ohne den bestehenden Build zu berühren.
|
||||
|
||||
### Muss umgesetzt werden
|
||||
- Modul `pdf-umbenenner-packaging` anlegen mit minimaler POM-Struktur.
|
||||
- Modul in Parent-POM (`<modules>`) und Reactor aufnehmen.
|
||||
- Abhängigkeit auf `pdf-umbenenner-bootstrap` (scope `runtime`) deklarieren.
|
||||
- Das Modul erzeugt im Normalbuild (`mvn clean verify`) **kein** zusätzliches Artefakt.
|
||||
- Keine Produktionsklassen, keine Tests – das Modul enthält ausschließlich
|
||||
Maven-Konfiguration und Ressourcen.
|
||||
- `package-info.java` entfällt (kein Java-Code im Modul).
|
||||
|
||||
### Explizit nicht Teil
|
||||
- Plugin-Konfiguration für jpackage
|
||||
- Maven-Profil `release`
|
||||
- Batch-Dateien
|
||||
- Icon
|
||||
|
||||
### Fertig wenn
|
||||
- das neue Modul im Reactor vorhanden ist,
|
||||
- `mvn clean verify` (ohne Profil) weiterhin fehlerfrei durchläuft,
|
||||
- keine bestehenden Module verändert wurden.
|
||||
|
||||
---
|
||||
|
||||
## AP-002 Ressourcen bereitstellen (Icon und Batch-Dateien)
|
||||
|
||||
### Voraussetzung
|
||||
AP-001 ist abgeschlossen.
|
||||
|
||||
### Ziel
|
||||
Icon und Batch-Dateien liegen als versionierte Ressourcen im Modul bereit.
|
||||
|
||||
### Muss umgesetzt werden
|
||||
|
||||
**Icon:**
|
||||
- Platzhalter-Icon `src/main/packaging/icon.ico` anlegen.
|
||||
- Das Icon ist ein valides `.ico`-Format (1×1 Pixel genügt als Platzhalter).
|
||||
- Kommentar in der Datei oder einer begleitenden `README-icon.md`:
|
||||
„Platzhalter – vor dem Release durch echtes Icon ersetzen."
|
||||
|
||||
**Batch-Dateien** unter `src/main/packaging/`:
|
||||
|
||||
`PDF-KI-Renamer.bat`:
|
||||
```bat
|
||||
@echo off
|
||||
"%~dp0PDF-KI-Renamer\PDF-KI-Renamer.exe" --headless %*
|
||||
```
|
||||
|
||||
`PDF-KI-Renamer-GUI.bat`:
|
||||
```bat
|
||||
@echo off
|
||||
"%~dp0PDF-KI-Renamer\PDF-KI-Renamer.exe" %*
|
||||
```
|
||||
|
||||
- `%~dp0` stellt sicher, dass die EXE relativ zur Batch-Datei gefunden wird,
|
||||
unabhängig vom aktuellen Arbeitsverzeichnis.
|
||||
- `%*` leitet alle weiteren Argumente (z. B. `--config`) durch.
|
||||
- Pfade mit Leerzeichen (z. B. `C:\Program Files\...`) sind durch die Anführungszeichen korrekt gequotet.
|
||||
|
||||
### Explizit nicht Teil
|
||||
- Plugin-Konfiguration
|
||||
- Kopieren der Batch-Dateien in das Ausgabeverzeichnis (folgt in AP-003)
|
||||
|
||||
### Fertig wenn
|
||||
- Icon und beide Batch-Dateien unter `src/main/packaging/` vorhanden sind,
|
||||
- `mvn clean verify` weiterhin fehlerfrei durchläuft.
|
||||
|
||||
---
|
||||
|
||||
## AP-003 Maven-Profil `release` mit jpackage konfigurieren
|
||||
|
||||
### Voraussetzung
|
||||
AP-002 ist abgeschlossen.
|
||||
|
||||
### Ziel
|
||||
`mvn clean package -P release` erzeugt auf der Windows-Entwicklungsmaschine
|
||||
(mit WiX Toolset im PATH) eine lauffähige Windows-EXE unter
|
||||
`pdf-umbenenner-packaging/target/dist/`.
|
||||
|
||||
### Technischer Hintergrund
|
||||
|
||||
Das Projekt verwendet ein **nicht-modulares Fat-JAR** (Shade-Plugin, kein JPMS).
|
||||
JavaFX-DLLs sind bereits im Shade-JAR enthalten (Windows-Classifier).
|
||||
Die Main-Class erweitert bewusst nicht `javafx.application.Application`
|
||||
(JavaFX-Launcher-Check-Workaround, dokumentiert in `betrieb.md`).
|
||||
|
||||
jpackage benötigt:
|
||||
1. Das Shade-JAR als Eingabe (`--input` + `--main-jar`)
|
||||
2. Eine minimale JRE (erzeugt via `jlink` oder automatisch durch jpackage)
|
||||
3. WiX Toolset im PATH (für `--type exe`)
|
||||
|
||||
Da das Projekt nicht modular ist, muss jpackage mit `--add-modules ALL-MODULE-PATH`
|
||||
oder einer expliziten Modulliste arbeiten. Die explizite Modulliste ist
|
||||
wartungsfreundlicher und wird bevorzugt.
|
||||
|
||||
### Muss umgesetzt werden
|
||||
|
||||
**Maven-Profil `release`** in der POM von `pdf-umbenenner-packaging`:
|
||||
|
||||
```xml
|
||||
<profile>
|
||||
<id>release</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- Shade-JAR aus Bootstrap-Modul ins Packaging-Verzeichnis kopieren -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-shade-jar</id>
|
||||
<phase>package</phase>
|
||||
<goals><goal>copy-dependencies</goal></goals>
|
||||
<configuration>
|
||||
<includeArtifactIds>pdf-umbenenner-bootstrap</includeArtifactIds>
|
||||
<outputDirectory>${project.build.directory}/jpackage-input</outputDirectory>
|
||||
<stripVersion>false</stripVersion>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- jpackage -->
|
||||
<plugin>
|
||||
<groupId>org.panteleyev</groupId>
|
||||
<artifactId>jpackage-maven-plugin</artifactId>
|
||||
<version>1.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>create-exe</id>
|
||||
<phase>package</phase>
|
||||
<goals><goal>jpackage</goal></goals>
|
||||
<configuration>
|
||||
<type>EXE</type>
|
||||
<name>PDF-KI-Renamer</name>
|
||||
<appVersion>${project.version}</appVersion>
|
||||
<vendor>gecheckt.de</vendor>
|
||||
<input>${project.build.directory}/jpackage-input</input>
|
||||
<mainJar>pdf-umbenenner-bootstrap-${project.version}.jar</mainJar>
|
||||
<mainClass>de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication</mainClass>
|
||||
<destination>${project.build.directory}/dist</destination>
|
||||
<icon>${project.basedir}/src/main/packaging/icon.ico</icon>
|
||||
<addModules>
|
||||
java.base,java.desktop,java.logging,java.naming,java.net.http,
|
||||
java.sql,java.xml,jdk.unsupported
|
||||
</addModules>
|
||||
<javaOptions>
|
||||
<javaOption>-Xms64m</javaOption>
|
||||
<javaOption>-Xmx512m</javaOption>
|
||||
</javaOptions>
|
||||
<winConsole>false</winConsole>
|
||||
<winShortcut>false</winShortcut>
|
||||
<winMenu>false</winMenu>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Batch-Dateien ins dist-Verzeichnis kopieren -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-batch-files</id>
|
||||
<phase>package</phase>
|
||||
<goals><goal>copy-resources</goal></goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/dist</outputDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/packaging</directory>
|
||||
<includes>
|
||||
<include>*.bat</include>
|
||||
</includes>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
```
|
||||
|
||||
**Wichtige Hinweise für Claude Code:**
|
||||
- Die Modulliste (`addModules`) ist ein Ausgangspunkt. Der tatsächliche Bedarf
|
||||
kann per `jdeps --print-module-deps` auf dem Shade-JAR ermittelt werden.
|
||||
Claude Code soll `jdeps` ausführen und die Modulliste anpassen.
|
||||
- `winConsole=false` sorgt dafür, dass kein CMD-Fenster beim GUI-Start erscheint.
|
||||
Für den headless-Start via Batch ist das akzeptabel (Ausgabe geht in Log-Dateien).
|
||||
- Die Plugin-Version `1.6.0` von `org.panteleyev:jpackage-maven-plugin` ist
|
||||
zu verifizieren – aktuelle Version per Maven Central prüfen.
|
||||
- Das `jpackage`-Plugin muss in `pluginManagement` im Parent-POM oder direkt
|
||||
in der Packaging-POM versioniert sein.
|
||||
|
||||
### Explizit nicht Teil
|
||||
- Anpassung von `betrieb.md` (folgt in AP-004)
|
||||
- Manuelle Ausführung oder Smoke-Test
|
||||
|
||||
### Fertig wenn
|
||||
- `mvn clean verify` (ohne Profil) weiterhin fehlerfrei durchläuft,
|
||||
- die POM-Konfiguration syntaktisch korrekt und vollständig ist,
|
||||
- `jdeps` auf dem Shade-JAR ausgeführt wurde und die Modulliste korrekt befüllt ist.
|
||||
|
||||
---
|
||||
|
||||
## AP-004 Dokumentation aktualisieren
|
||||
|
||||
### Voraussetzung
|
||||
AP-003 ist abgeschlossen.
|
||||
|
||||
### Ziel
|
||||
Die Projektdokumentation spiegelt den V2.5-Stand korrekt wider.
|
||||
|
||||
### Muss umgesetzt werden
|
||||
|
||||
**`betrieb.md` – Abschnitt „Keine EXE, kein Installer" ersetzen durch:**
|
||||
|
||||
```markdown
|
||||
### Windows-EXE (V2.5)
|
||||
|
||||
Ab V2.5 steht neben dem Shade-JAR ein zweites Distributionsartefakt bereit:
|
||||
eine **native Windows-EXE** für Windows 10/11 (x64) und Windows Server 2022 (x64).
|
||||
|
||||
Die EXE enthält eine eingebettete JRE 21 und benötigt keine separate Java-Installation
|
||||
auf dem Zielsystem.
|
||||
|
||||
**Voraussetzungen für den EXE-Build (nur auf der Entwicklungsmaschine):**
|
||||
- Windows x64
|
||||
- JDK 21 im PATH
|
||||
- [WiX Toolset 3.x](https://wixtoolset.org/) im PATH
|
||||
|
||||
**EXE bauen:**
|
||||
```powershell
|
||||
.\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests
|
||||
```
|
||||
|
||||
Das Ergebnis liegt unter:
|
||||
```
|
||||
pdf-umbenenner-packaging/target/dist/
|
||||
PDF-KI-Renamer/ ← Anwendungsverzeichnis mit EXE und eingebetteter JRE
|
||||
PDF-KI-Renamer.bat ← Headless-Start
|
||||
PDF-KI-Renamer-GUI.bat ← GUI-Start
|
||||
```
|
||||
|
||||
**Hinweis:** Die EXE ist nicht signiert. Beim ersten Start auf einem neuen System
|
||||
erscheint eine Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
|
||||
bestätigt werden muss.
|
||||
```
|
||||
|
||||
**`betrieb.md` – Abschnitt „Voraussetzungen" aktualisieren:**
|
||||
- Java 21 ist für Endnutzer der EXE **nicht** mehr erforderlich (eingebettet).
|
||||
- Hinweis ergänzen: „Bei Verwendung des Shade-JAR direkt: Java 21 JRE erforderlich."
|
||||
|
||||
**`CLAUDE.md` aktualisieren** (falls vorhanden):
|
||||
- Hinweis auf Profil `release` und WiX-Abhängigkeit ergänzen.
|
||||
- Build-Kommando für EXE dokumentieren.
|
||||
|
||||
### Fertig wenn
|
||||
- `betrieb.md` den neuen Abschnitt enthält,
|
||||
- die Voraussetzungen korrekt aktualisiert sind,
|
||||
- `mvn clean verify` weiterhin fehlerfrei durchläuft.
|
||||
@@ -0,0 +1,216 @@
|
||||
# M15 - Arbeitspakete
|
||||
|
||||
## Geltungsbereich
|
||||
|
||||
Dieses Dokument beschreibt ausschließlich die Arbeitspakete für den definierten Meilenstein
|
||||
**M15 – MSI-Installer (V3.0)**.
|
||||
|
||||
Der Stand **V2.5** (M14 abgeschlossen) wird als vollständig umgesetzt vorausgesetzt:
|
||||
- Modul `pdf-umbenenner-packaging` existiert
|
||||
- Maven-Profil `release` ist konfiguriert
|
||||
- `icon.ico`, `PDF-KI-Renamer.bat`, `PDF-KI-Renamer-GUI.bat` liegen unter
|
||||
`pdf-umbenenner-packaging/src/main/packaging/`
|
||||
|
||||
Die Arbeitspakete sind so geschnitten, dass Opus 4.7 sie in einem Durchgang
|
||||
vollständig umsetzen kann. Nach jedem Arbeitspaket muss `mvn clean verify`
|
||||
(ohne Profil) fehlerfrei durchlaufen.
|
||||
|
||||
---
|
||||
|
||||
## Zielbild von M15
|
||||
|
||||
Nach Abschluss von M15 erzeugt `mvn clean package -P release` einen vollständigen
|
||||
**MSI-Installer** (`PDF-KI-Renamer-2.5.0.msi`) der:
|
||||
|
||||
- die Anwendung nach `C:\Program Files\PDF KI Renamer\` installiert,
|
||||
- eine Beispiel-Konfiguration nach
|
||||
`C:\ProgramData\PDF KI Renamer\config\application.example.properties` ablegt,
|
||||
- beide Batch-Dateien ins Installationsverzeichnis legt,
|
||||
- einen Startmenü-Eintrag für den GUI-Start erstellt,
|
||||
- einen Desktop-Shortcut erstellt,
|
||||
- über „Programme und Features" sauber deinstallierbar ist.
|
||||
|
||||
---
|
||||
|
||||
## Abgrenzungen
|
||||
|
||||
### Explizit nicht Bestandteil von M15
|
||||
|
||||
- Automatische Konfigurationsauflösung aus `ProgramData` (bleibt `--config`-Sache)
|
||||
- Code-Signing des MSI
|
||||
- Upgrade-Logik (MajorUpgrade, automatisches Deinstallieren alter Versionen)
|
||||
- Änderungen an fachlicher Logik, GUI, headless-Betrieb oder Persistenz
|
||||
- Neue Tests
|
||||
|
||||
### Unveränderte Leitplanken
|
||||
|
||||
- `--type MSI` ersetzt `--type EXE` im Profil `release`
|
||||
- Der Normalbuild (`mvn clean verify`) bleibt unverändert
|
||||
- Bestehende Module außer `pdf-umbenenner-packaging` werden nicht angefasst
|
||||
|
||||
---
|
||||
|
||||
## Verbindliche M15-Regeln
|
||||
|
||||
### 1. Installationsverzeichnis
|
||||
`C:\Program Files\PDF KI Renamer\`
|
||||
|
||||
### 2. Konfigurationsverzeichnis
|
||||
`C:\ProgramData\PDF KI Renamer\config\`
|
||||
|
||||
Die Beispiel-Config wird aus `docs/examples/application.properties` des Projekts
|
||||
in dieses Verzeichnis kopiert und als `application.example.properties` abgelegt.
|
||||
|
||||
### 3. Batch-Dateien
|
||||
Beide Batch-Dateien landen im Installationsverzeichnis.
|
||||
Die Pfade in den Batch-Dateien müssen auf das Installationsverzeichnis angepasst werden
|
||||
(nicht mehr relativ per `%~dp0`, sondern absolut via Installationspfad-Variable oder
|
||||
weiterhin relativ – beides ist akzeptabel solange es funktioniert).
|
||||
|
||||
### 4. Startmenü & Desktop
|
||||
- Startmenü-Gruppe: `PDF KI Renamer`
|
||||
- Startmenü-Eintrag: `PDF KI Renamer` → startet GUI
|
||||
- Desktop-Shortcut: `PDF KI Renamer` → startet GUI
|
||||
|
||||
### 5. Deinstallation
|
||||
Saubere Deinstallation über „Programme und Features". Vom Installer angelegte
|
||||
Dateien werden entfernt. Nutzerdaten in `ProgramData` (Konfiguration, Logs, DB)
|
||||
werden **nicht** gelöscht.
|
||||
|
||||
---
|
||||
|
||||
## AP-001 MSI-Typ und Installer-Ressourcen vorbereiten
|
||||
|
||||
### Voraussetzung
|
||||
M14 ist abgeschlossen. `mvn clean verify` ist grün.
|
||||
|
||||
### Ziel
|
||||
Das Profil `release` erzeugt einen MSI statt einer EXE,
|
||||
und alle notwendigen Installer-Ressourcen liegen bereit.
|
||||
|
||||
### Muss umgesetzt werden
|
||||
|
||||
1. In `pdf-umbenenner-packaging/pom.xml` im Profil `release`:
|
||||
- `<type>EXE</type>` → `<type>MSI</type>`
|
||||
- Folgende Windows-spezifische jpackage-Optionen ergänzen:
|
||||
```xml
|
||||
<winShortcut>true</winShortcut>
|
||||
<winMenu>true</winMenu>
|
||||
<winMenuGroup>PDF KI Renamer</winMenuGroup>
|
||||
<winDirChooser>true</winDirChooser>
|
||||
<winShortcutPrompt>false</winShortcutPrompt>
|
||||
<installDir>PDF KI Renamer</installDir>
|
||||
```
|
||||
|
||||
2. Beispiel-Konfiguration als Installer-Ressource bereitstellen:
|
||||
- `docs/examples/application.properties` nach
|
||||
`pdf-umbenenner-packaging/src/main/packaging/application.example.properties`
|
||||
kopieren (als versionierte Kopie im Modul – nicht das Original verschieben).
|
||||
|
||||
3. `mvn clean verify` muss weiterhin grün bleiben.
|
||||
|
||||
### Fertig wenn
|
||||
- `<type>MSI</type>` in der POM gesetzt
|
||||
- Windows-Optionen konfiguriert
|
||||
- `application.example.properties` unter `src/main/packaging/` vorhanden
|
||||
- `mvn clean verify` grün
|
||||
|
||||
---
|
||||
|
||||
## AP-002 ProgramData-Verzeichnis und Beispiel-Config im Installer verankern
|
||||
|
||||
### Voraussetzung
|
||||
AP-001 ist abgeschlossen.
|
||||
|
||||
### Ziel
|
||||
Der MSI-Installer legt beim Installieren die Beispiel-Config unter
|
||||
`C:\ProgramData\PDF KI Renamer\config\application.example.properties` ab.
|
||||
|
||||
### Technischer Hintergrund
|
||||
|
||||
jpackage unterstützt `--app-content` zum Hinzufügen zusätzlicher Dateien
|
||||
in das Anwendungs-Image. Diese landen jedoch im Installationsverzeichnis,
|
||||
nicht in `ProgramData`.
|
||||
|
||||
Für `ProgramData` gibt es zwei Wege:
|
||||
- **Weg A**: jpackage `--resource-dir` mit WiX-Override (komplex, fehleranfällig)
|
||||
- **Weg B**: Die Beispiel-Config über `--app-content` ins Installationsverzeichnis
|
||||
legen und in der Dokumentation beschreiben, dass der Nutzer sie nach
|
||||
`ProgramData` kopieren soll (einfach, robust)
|
||||
|
||||
**Verbindlich für M15: Weg B.**
|
||||
|
||||
### Muss umgesetzt werden
|
||||
|
||||
1. `application.example.properties` via `--app-content` in das
|
||||
Anwendungsverzeichnis einbinden:
|
||||
```xml
|
||||
<appContent>
|
||||
<appContent>src/main/packaging/application.example.properties</appContent>
|
||||
</appContent>
|
||||
```
|
||||
|
||||
2. `mvn clean verify` muss weiterhin grün bleiben.
|
||||
|
||||
### Fertig wenn
|
||||
- `application.example.properties` ist in der jpackage-Konfiguration als
|
||||
`appContent` eingebunden
|
||||
- `mvn clean verify` grün
|
||||
|
||||
---
|
||||
|
||||
## AP-003 Desktop-Shortcut konfigurieren
|
||||
|
||||
### Voraussetzung
|
||||
AP-002 ist abgeschlossen.
|
||||
|
||||
### Ziel
|
||||
Der Installer erstellt zusätzlich einen Desktop-Shortcut.
|
||||
|
||||
### Technischer Hintergrund
|
||||
|
||||
jpackage unterstützt Desktop-Shortcuts über `--win-shortcut`.
|
||||
`<winShortcut>true</winShortcut>` ist bereits in AP-001 gesetzt –
|
||||
das erzeugt jedoch primär einen Startmenü-Eintrag.
|
||||
|
||||
Für einen **Desktop**-Shortcut ist ein zusätzlicher WiX-Override nötig.
|
||||
Prüfe zunächst ob `<winShortcut>true</winShortcut>` in Kombination mit
|
||||
`<winShortcutPrompt>false</winShortcutPrompt>` bereits einen Desktop-Shortcut erzeugt.
|
||||
Falls nicht, dokumentiere dies als bekannte Einschränkung in `betrieb.md`
|
||||
und überspringe den WiX-Override (zu komplex für M15).
|
||||
|
||||
### Fertig wenn
|
||||
- Entweder Desktop-Shortcut funktioniert, oder
|
||||
- die Einschränkung ist in `betrieb.md` dokumentiert
|
||||
- `mvn clean verify` grün
|
||||
|
||||
---
|
||||
|
||||
## AP-004 Dokumentation aktualisieren
|
||||
|
||||
### Voraussetzung
|
||||
AP-001 bis AP-003 sind abgeschlossen.
|
||||
|
||||
### Ziel
|
||||
Die Projektdokumentation spiegelt den V3.0-Stand korrekt wider.
|
||||
|
||||
### Muss umgesetzt werden
|
||||
|
||||
1. `docs/betrieb.md` – Abschnitt „Windows-EXE (V2.5)" erweitern zu
|
||||
„Windows-Installer (V3.0)":
|
||||
- MSI-Build-Kommando dokumentieren
|
||||
- Installationsverzeichnis dokumentieren
|
||||
- Hinweis: Beispiel-Config liegt nach Installation im Installationsverzeichnis,
|
||||
muss manuell nach `C:\ProgramData\PDF KI Renamer\config\` kopiert und
|
||||
angepasst werden
|
||||
- Hinweis auf SmartScreen-Warnung (kein Code-Signing)
|
||||
- Headless-Betrieb: Beispiel-Aufruf mit `--config`
|
||||
|
||||
2. `CLAUDE.md` aktualisieren:
|
||||
- Build-Kommando für MSI ergänzen
|
||||
|
||||
### Fertig wenn
|
||||
- `betrieb.md` vollständig aktualisiert
|
||||
- `CLAUDE.md` aktualisiert
|
||||
- `mvn clean verify` grün
|
||||
- M15 vollständig abgeschlossen
|
||||
@@ -4,7 +4,7 @@
|
||||
<parent>
|
||||
<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>
|
||||
@@ -39,6 +39,31 @@
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<!-- JavaFX-Swing-Interop: wird für SwingFXUtils.toFXImage (BufferedImage -> FX Image) benötigt -->
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-swing</artifactId>
|
||||
<version>21.0.2</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
|
||||
<!-- PDF-Vorschau: PDFBox für direktes Rendering einzelner Seiten in BufferedImages -->
|
||||
<dependency>
|
||||
<groupId>org.apache.pdfbox</groupId>
|
||||
<artifactId>pdfbox</artifactId>
|
||||
</dependency>
|
||||
<!-- JBIG2-Codec für PDF-Bilddecodierung -->
|
||||
<dependency>
|
||||
<groupId>org.apache.pdfbox</groupId>
|
||||
<artifactId>jbig2-imageio</artifactId>
|
||||
<version>3.0.4</version>
|
||||
</dependency>
|
||||
<!-- JPEG2000-Codec für erweiterte PDF-Bilddecodierung -->
|
||||
<dependency>
|
||||
<groupId>com.github.jai-imageio</groupId>
|
||||
<artifactId>jai-imageio-jpeg2000</artifactId>
|
||||
<version>1.4.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Logging -->
|
||||
<dependency>
|
||||
@@ -47,6 +72,19 @@
|
||||
</dependency>
|
||||
|
||||
<!-- Test dependencies -->
|
||||
<!--
|
||||
log4j-core on the test classpath provides the logging implementation for
|
||||
tests that instantiate production classes using LogManager.getLogger.
|
||||
Without it, Log4j2 falls back to SimpleLogger during test execution and
|
||||
prints "Log4j2 could not find a logging implementation" at test start.
|
||||
The production classpath is unaffected; log4j-core is supplied by the
|
||||
bootstrap module in the shaded runtime JAR.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -92,8 +130,8 @@
|
||||
prism.order=sw enables software rendering (no GPU required);
|
||||
prism.text=t2k selects the T2K text rasterizer (headless-safe);
|
||||
java.awt.headless=true signals headless mode to AWT/Swing interop layers.
|
||||
The add-opens args are required for JavaFX internal access patterns used
|
||||
by Monocle and the Platform.startup API in Java 21 module context.
|
||||
Note: module-opening arguments for javafx.graphics are no longer required.
|
||||
Modern JavaFX (21.x) with Monocle on Java 21 works without explicit module opening.
|
||||
-->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
@@ -106,8 +144,6 @@
|
||||
-Dprism.order=sw
|
||||
-Dprism.text=t2k
|
||||
-Djava.awt.headless=true
|
||||
--add-opens=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED
|
||||
--add-opens=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED
|
||||
</argLine>
|
||||
</configuration>
|
||||
</plugin>
|
||||
@@ -169,11 +205,13 @@
|
||||
</goals>
|
||||
<configuration>
|
||||
<!--
|
||||
GUI adapter mutation thresholds are intentionally low: the JavaFX
|
||||
Application lifecycle requires a display or headless Monocle runtime
|
||||
which is introduced in a later work package. Once Monocle smoke tests
|
||||
are in place, these thresholds will be raised.
|
||||
GUI adapter: PIT is skipped entirely. The JavaFX Application lifecycle
|
||||
cannot be meaningfully mutation-tested without a running display or
|
||||
Monocle runtime, and the remaining testable surface is too small to
|
||||
produce useful mutation scores. Mutation analysis is deferred until
|
||||
GUI coverage matures.
|
||||
-->
|
||||
<skip>true</skip>
|
||||
<coverageThreshold>0</coverageThreshold>
|
||||
<mutationThreshold>0</mutationThreshold>
|
||||
</configuration>
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javafx.stage.FileChooser;
|
||||
|
||||
/**
|
||||
* Funktionales Interface fuer den Datei-Auswaehldialog der GUI.
|
||||
* <p>
|
||||
* Kapselt die Abhaengigkeit zum nativen {@link FileChooser} in einem
|
||||
* injizierbaren Hook, der in Tests durch eine einfache Lambda-Implementierung
|
||||
* ersetzt werden kann. Die Standardimplementierung oeffnet einen echten
|
||||
* nativen Datei-Dialog; Test-Stubs koennen einen festen Pfad zurueckgeben
|
||||
* oder {@code null} simulieren (Abbrechen).
|
||||
* <p>
|
||||
* Im Gegensatz zur frueheren {@code BiFunction}-Variante nimmt dieser Hook
|
||||
* auch die Liste der {@link FileChooser.ExtensionFilter} entgegen, damit der
|
||||
* native Dialog die Filter tatsaechlich anwenden kann.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface FilePickerDialog {
|
||||
|
||||
/**
|
||||
* Oeffnet den Datei-Auswaehldialog und gibt den ausgewaehlten absoluten
|
||||
* Pfad zurueck.
|
||||
*
|
||||
* @param title der Titel des Dialogs
|
||||
* @param initialPath der Anfangspfad als Hinweis; darf leer oder {@code null} sein
|
||||
* @param filters Liste der Dateitypfilter; darf leer sein, aber nicht {@code null}
|
||||
* @return der ausgewaehlte absolute Pfad als String, oder {@code null} wenn abgebrochen
|
||||
*/
|
||||
String pick(String title, String initialPath, List<FileChooser.ExtensionFilter> filters);
|
||||
}
|
||||
+2
-2
@@ -2,11 +2,11 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import javafx.application.Application;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import javafx.application.Application;
|
||||
|
||||
/**
|
||||
* Entry point for the JavaFX desktop GUI inbound adapter.
|
||||
* <p>
|
||||
|
||||
+1073
-236
File diff suppressed because it is too large
Load Diff
+5
@@ -6,6 +6,11 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
public class GuiConfigurationLoadException extends RuntimeException {
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
private static final long serialVersionUID = 5039061738684738963L;
|
||||
|
||||
/**
|
||||
* Creates a new load exception.
|
||||
*
|
||||
* @param message the exception message
|
||||
|
||||
+5
@@ -9,6 +9,11 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
public class GuiConfigurationWriteException extends RuntimeException {
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
private static final long serialVersionUID = -6970750036865888915L;
|
||||
|
||||
/**
|
||||
* Creates an exception with the given message.
|
||||
*
|
||||
* @param message the error description; must not be {@code null}
|
||||
|
||||
+3
-3
@@ -5,6 +5,9 @@ import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
@@ -15,9 +18,6 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.Correctio
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Koordiniert den gesammelten Bestätigungsdialog und die anschließende Ausführung
|
||||
* schreibender Korrekturmaßnahmen nach einem technischen Gesamttest.
|
||||
|
||||
+34
-8
@@ -1,13 +1,16 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
|
||||
@@ -19,9 +22,6 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalog
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Coordinates asynchronous model catalogue retrieval for the GUI provider section.
|
||||
* <p>
|
||||
@@ -36,6 +36,13 @@ import org.apache.logging.log4j.Logger;
|
||||
* 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>
|
||||
@@ -63,6 +70,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
|
||||
@@ -145,12 +160,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();
|
||||
});
|
||||
@@ -195,14 +221,14 @@ public final class GuiModelCatalogCoordinator {
|
||||
String message = "Provider " + displayName
|
||||
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf"));
|
||||
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||
LOG.warn("GUI-Modellabruf: {} (Provider: {})", 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.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||
}
|
||||
case ModelCatalogResult.TechnicalFailure failure -> {
|
||||
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
|
||||
|
||||
+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);
|
||||
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("Prompt-Datei speichern (atomar, UTF-8)."));
|
||||
saveButton.setOnAction(e -> requestSave());
|
||||
|
||||
resetButton.setTooltip(new Tooltip("Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern."));
|
||||
resetButton.setOnAction(e -> resetToDefault());
|
||||
|
||||
createDefaultButton.setTooltip(new Tooltip(
|
||||
"Standard-Prompt-Datei am konfigurierten Pfad 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);
|
||||
}
|
||||
}
|
||||
+310
-20
@@ -2,19 +2,31 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalDocumentContextPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Immutable startup data for the GUI adapter.
|
||||
@@ -26,9 +38,18 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.Technical
|
||||
* API key provenance from environment variables, the {@link ProviderTechnicalTestService}
|
||||
* used to execute provider-specific technical checks, the {@link PathCheckPort}
|
||||
* used to verify filesystem path accessibility for configuration values, the
|
||||
* {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, and the
|
||||
* {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, the
|
||||
* {@link CorrectionExecutionService} used to execute corrective actions after a
|
||||
* technical test run has been confirmed by the user.
|
||||
* technical test run has been confirmed by the user, the {@link GuiBatchRunLauncher} used
|
||||
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
|
||||
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
|
||||
* reset the persistence status of selected documents, and the
|
||||
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI,
|
||||
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
|
||||
* folder for documents that have not yet been successfully processed, and
|
||||
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
|
||||
* context for documents that were skipped in the current run, and the resolved application
|
||||
* version string that the status bar displays at the bottom of the main window.
|
||||
* <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.
|
||||
@@ -43,10 +64,23 @@ public record GuiStartupContext(
|
||||
ProviderTechnicalTestService providerTechnicalTestService,
|
||||
PathCheckPort pathCheckPort,
|
||||
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||
CorrectionExecutionService correctionExecutionService) {
|
||||
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) {
|
||||
|
||||
/**
|
||||
* Creates a startup context.
|
||||
* Creates a fully wired startup context.
|
||||
*
|
||||
* @param initialState initial editor state; must not be {@code null}
|
||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||
@@ -58,6 +92,24 @@ public record GuiStartupContext(
|
||||
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
|
||||
* @param miniRunLauncher bridge that executes a targeted mini-run for selected
|
||||
* documents; must not be {@code null}
|
||||
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
|
||||
* documents; must not be {@code null}
|
||||
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
|
||||
* must not be {@code null}
|
||||
* @param manualFileCopyPort bridge that copies a source file to the target folder for
|
||||
* documents that have not yet been successfully processed;
|
||||
* must not be {@code null}
|
||||
* @param historicalDocumentContextPort bridge that resolves the historical processing context
|
||||
* for skipped documents; must not be {@code null}
|
||||
* @param applicationVersion resolved application version string shown in the status
|
||||
* bar; {@code null} defaults to {@code "dev"}
|
||||
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
|
||||
* {@code null} sein
|
||||
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
|
||||
* darf nicht {@code null} sein
|
||||
*/
|
||||
public GuiStartupContext {
|
||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||
@@ -78,22 +130,192 @@ public record GuiStartupContext(
|
||||
"technicalTestOrchestrator must not be null");
|
||||
correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
|
||||
"correctionExecutionService must not be null");
|
||||
batchRunLauncher = Objects.requireNonNull(batchRunLauncher,
|
||||
"batchRunLauncher must not be null");
|
||||
miniRunLauncher = Objects.requireNonNull(miniRunLauncher,
|
||||
"miniRunLauncher must not be null");
|
||||
resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
|
||||
"resetDocumentStatusPort must not be null");
|
||||
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
|
||||
"manualFileRenamePort must not be null");
|
||||
manualFileCopyPort = Objects.requireNonNull(manualFileCopyPort,
|
||||
"manualFileCopyPort must not be null");
|
||||
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
|
||||
"historicalDocumentContextPort must not be null");
|
||||
// Null-Fallback für Testumgebungen ohne gepacktes JAR
|
||||
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
|
||||
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||
historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
|
||||
"historyOverviewPort must not be null");
|
||||
historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
|
||||
"historyDetailsPort must not be null");
|
||||
historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
|
||||
"historyResetDocumentStatusPort must not be null");
|
||||
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
|
||||
"deleteDocumentHistoryPort must not be null");
|
||||
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
|
||||
"promptEditorPortFactory must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a blank startup context with no loader or writer side effects, a no-op model
|
||||
* catalogue port, a no-op API key resolution port, a no-op provider technical test service,
|
||||
* a no-op path check port, a no-op technical test orchestrator, and a no-op
|
||||
* correction execution service.
|
||||
* Backward-compatible constructor that fills the manual-rename port with a no-op
|
||||
* implementation.
|
||||
*
|
||||
* @param initialState initial editor state; must not be {@code null}
|
||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
||||
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
|
||||
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
|
||||
* @param miniRunLauncher bridge that executes a targeted mini-run for selected
|
||||
* documents; must not be {@code null}
|
||||
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
|
||||
* documents; must not be {@code null}
|
||||
*/
|
||||
public GuiStartupContext(
|
||||
GuiConfigurationEditorState initialState,
|
||||
Optional<String> startupNotice,
|
||||
GuiConfigurationFileLoader configurationFileLoader,
|
||||
GuiConfigurationFileWriter configurationFileWriter,
|
||||
AiModelCatalogPort modelCatalogPort,
|
||||
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||
ProviderTechnicalTestService providerTechnicalTestService,
|
||||
PathCheckPort pathCheckPort,
|
||||
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||
CorrectionExecutionService correctionExecutionService,
|
||||
GuiBatchRunLauncher batchRunLauncher,
|
||||
GuiMiniRunLauncher miniRunLauncher,
|
||||
GuiResetDocumentStatusPort resetDocumentStatusPort) {
|
||||
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
||||
rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor that fills the mini-run launcher, reset port and
|
||||
* manual-rename port with no-op implementations.
|
||||
*
|
||||
* @param initialState initial editor state; must not be {@code null}
|
||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
||||
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
|
||||
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
|
||||
*/
|
||||
public GuiStartupContext(
|
||||
GuiConfigurationEditorState initialState,
|
||||
Optional<String> startupNotice,
|
||||
GuiConfigurationFileLoader configurationFileLoader,
|
||||
GuiConfigurationFileWriter configurationFileWriter,
|
||||
AiModelCatalogPort modelCatalogPort,
|
||||
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||
ProviderTechnicalTestService providerTechnicalTestService,
|
||||
PathCheckPort pathCheckPort,
|
||||
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||
CorrectionExecutionService correctionExecutionService,
|
||||
GuiBatchRunLauncher batchRunLauncher) {
|
||||
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
||||
rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor that fills the processing-run launcher, mini-run
|
||||
* launcher, reset port and manual-rename port with no-op implementations.
|
||||
* <p>
|
||||
* Preserves existing callers that were written before the processing-run tab was added.
|
||||
*
|
||||
* @param initialState initial editor state; must not be {@code null}
|
||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
||||
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
|
||||
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||
*/
|
||||
public GuiStartupContext(
|
||||
GuiConfigurationEditorState initialState,
|
||||
Optional<String> startupNotice,
|
||||
GuiConfigurationFileLoader configurationFileLoader,
|
||||
GuiConfigurationFileWriter configurationFileWriter,
|
||||
AiModelCatalogPort modelCatalogPort,
|
||||
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||
ProviderTechnicalTestService providerTechnicalTestService,
|
||||
PathCheckPort pathCheckPort,
|
||||
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||
CorrectionExecutionService correctionExecutionService) {
|
||||
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||
technicalTestOrchestrator, correctionExecutionService,
|
||||
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
|
||||
}
|
||||
|
||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||
return (configPath, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
|
||||
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
|
||||
}
|
||||
|
||||
private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
|
||||
return (configPath, filter, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
|
||||
"Kein Mini-Run-Launcher in diesem Startkontext verfügbar.");
|
||||
}
|
||||
|
||||
private static GuiResetDocumentStatusPort rejectingResetPort() {
|
||||
return (configPath, fingerprints) -> {
|
||||
java.util.Map<DocumentFingerprint, String> failures = new java.util.HashMap<>();
|
||||
for (DocumentFingerprint fp : fingerprints) {
|
||||
failures.put(fp, "Kein Reset-Port in diesem Startkontext verfügbar.");
|
||||
}
|
||||
return new ResetDocumentStatusResult(fingerprints.size(), Set.of(), failures);
|
||||
};
|
||||
}
|
||||
|
||||
private static GuiManualFileRenamePort rejectingManualFileRenamePort() {
|
||||
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
|
||||
.ManualFileRenameFileSystemFailure(
|
||||
"Kein Umbenennungs-Port in diesem Startkontext verfügbar.");
|
||||
}
|
||||
|
||||
private static GuiManualFileCopyPort rejectingManualFileCopyPort() {
|
||||
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
|
||||
.ManualFileCopyFileSystemFailure(
|
||||
"Kein Kopier-Port in diesem Startkontext verfügbar.");
|
||||
}
|
||||
|
||||
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
|
||||
return (configPath, fingerprint) -> java.util.Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a blank startup context with no-op implementations for all ports and services.
|
||||
* <p>
|
||||
* The no-op model catalogue port always returns {@code IncompleteConfiguration}.
|
||||
* The no-op API key resolution port always returns {@code ABSENT}.
|
||||
* The no-op provider technical test service uses the no-op ports above.
|
||||
* The no-op path check port always returns {@code false} for all checks.
|
||||
* The no-op technical test orchestrator returns a report where all checkpoints are
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult.NotApplicable}.
|
||||
* The no-op correction execution service uses a no-op {@link ResourceCreationPort} that always
|
||||
* returns {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted}.
|
||||
* This is safe for environments where no Bootstrap wiring is present, such as isolated
|
||||
* GUI tests.
|
||||
*
|
||||
@@ -122,7 +344,8 @@ 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
|
||||
@@ -147,6 +370,9 @@ public record GuiStartupContext(
|
||||
}
|
||||
};
|
||||
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
|
||||
GuiBatchRunLauncher noOpBatchRunLauncher = (configPath, observer, token) ->
|
||||
GuiBatchRunLaunchOutcome.rejected(
|
||||
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
|
||||
return new GuiStartupContext(
|
||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||
startupNotice,
|
||||
@@ -157,6 +383,70 @@ public record GuiStartupContext(
|
||||
noOpTestService,
|
||||
noOpPathCheckPort,
|
||||
noOpOrchestrator,
|
||||
noOpCorrectionService);
|
||||
noOpCorrectionService,
|
||||
noOpBatchRunLauncher,
|
||||
rejectingMiniRunLauncher(),
|
||||
rejectingResetPort(),
|
||||
rejectingManualFileRenamePort(),
|
||||
rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort(),
|
||||
"dev",
|
||||
noOpPromptEditorPort(),
|
||||
noOpHistoryOverviewPort(),
|
||||
noOpHistoryDetailsPort(),
|
||||
noOpHistoryResetPort(),
|
||||
noOpDeleteHistoryPort(),
|
||||
noOpPromptEditorPortFactory());
|
||||
}
|
||||
|
||||
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", "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
|
||||
"Kein Prompt-Editor-Port in diesem Startkontext verfügbar.", 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, "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 */ };
|
||||
}
|
||||
}
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
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 {
|
||||
|
||||
/** 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("-fx-font-size: 11px; -fx-text-fill: #555555;");
|
||||
|
||||
// Mittleres Segment: Provider und Modell
|
||||
this.providerLabel = new Label(KEIN_PROFIL_TEXT);
|
||||
this.providerLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
|
||||
this.providerLabel.setAlignment(Pos.CENTER);
|
||||
|
||||
// Rechtes Segment: Konfigurationspfad
|
||||
this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
|
||||
this.configPathLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
|
||||
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;
|
||||
}
|
||||
}
|
||||
+23
-9
@@ -6,6 +6,9 @@ import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||
@@ -16,9 +19,6 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.Technical
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestRequest;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Koordiniert die asynchrone Ausführung der Aktion „Technische Tests ausführen"
|
||||
* für den GUI-Konfigurationseditor.
|
||||
@@ -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.");
|
||||
|
||||
@@ -145,17 +160,15 @@ public final class GuiTechnicalTestCoordinator {
|
||||
/**
|
||||
* Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
|
||||
* <p>
|
||||
* Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jeden
|
||||
* Checkpoint-Ergebnis einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
|
||||
* angehängt.
|
||||
* Fügt für jedes Checkpoint-Ergebnis einen neuen Eintrag zur geteilten Nachrichtenliste
|
||||
* hinzu. Die Liste wurde zuvor in {@link #triggerTechnicalTests()} geleert, sodass jeder
|
||||
* Aufruf einen frischen Stand erzeugt. Zusätzlich wird eine Zusammenfassung angehängt.
|
||||
* <p>
|
||||
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
|
||||
*
|
||||
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
|
||||
*/
|
||||
private void applyResult(TechnicalTestReport report) {
|
||||
// Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
|
||||
pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
|
||||
|
||||
long successCount = 0;
|
||||
long failureErrorCount = 0;
|
||||
@@ -228,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";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
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.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verarbeitungslauf-Tab – Dateiname-Editor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 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.";
|
||||
|
||||
/** Nicht instanziierbar – reine Konstantenklasse. */
|
||||
private GuiTooltipTexts() {
|
||||
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||
}
|
||||
}
|
||||
+77
-8
@@ -1,12 +1,17 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import javafx.application.Application;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.WindowEvent;
|
||||
|
||||
/**
|
||||
* JavaFX application entry point for the PDF-Umbenenner GUI inbound adapter.
|
||||
* <p>
|
||||
@@ -18,6 +23,9 @@ import org.apache.logging.log4j.Logger;
|
||||
* {@code titleUpdateListener} hook. The close-request handler is installed through
|
||||
* {@link GuiConfigurationEditorWorkspace#installCloseRequestHandler(Stage)} so that
|
||||
* unsaved changes are protected when the user tries to close the window.
|
||||
*
|
||||
* <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert.
|
||||
* Über das Tray-Kontextmenü kann das Fenster wieder geöffnet oder die Anwendung beendet werden.
|
||||
*/
|
||||
public class PdfUmbenennerGuiApplication extends Application {
|
||||
|
||||
@@ -25,6 +33,8 @@ public class PdfUmbenennerGuiApplication extends Application {
|
||||
private static final double DEFAULT_WIDTH = 1100;
|
||||
private static final double DEFAULT_HEIGHT = 800;
|
||||
|
||||
private SystemTrayManager trayManager;
|
||||
|
||||
/**
|
||||
* Creates a new instance of the JavaFX application.
|
||||
*/
|
||||
@@ -35,9 +45,10 @@ public class PdfUmbenennerGuiApplication extends Application {
|
||||
/**
|
||||
* Initializes and shows the primary stage.
|
||||
* <p>
|
||||
* Lädt die Anwendungs-Icons in allen verfügbaren Größen und setzt sie am Fenster.
|
||||
* Wires the workspace title-update listener to the stage title so any dirty-state change
|
||||
* causes an immediate window-title refresh. Also installs the close-request handler that
|
||||
* guards unsaved changes before the window is closed.
|
||||
* 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.
|
||||
*
|
||||
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
|
||||
*/
|
||||
@@ -45,31 +56,89 @@ public class PdfUmbenennerGuiApplication extends Application {
|
||||
public void start(Stage primaryStage) {
|
||||
LOG.info("GUI: JavaFX-Oberfläche wird initialisiert.");
|
||||
|
||||
// Anwendungs-Icons laden; JavaFX wählt je nach Kontext automatisch die passende Größe
|
||||
primaryStage.getIcons().addAll(
|
||||
new Image(getClass().getResourceAsStream("/icons/Icon16.png")),
|
||||
new Image(getClass().getResourceAsStream("/icons/Icon32.png")),
|
||||
new Image(getClass().getResourceAsStream("/icons/Icon64.png")),
|
||||
new Image(getClass().getResourceAsStream("/icons/Icon128.png"))
|
||||
);
|
||||
|
||||
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
|
||||
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
|
||||
|
||||
// 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(startupContext.applicationVersion());
|
||||
workspace.statusBarStateListener = statusBar::applyEditorState;
|
||||
|
||||
// Statuszeile unterhalb des Workspace-Inhalts einbetten
|
||||
BorderPane outerLayout = new BorderPane();
|
||||
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);
|
||||
|
||||
// Install the close-request handler that protects unsaved changes.
|
||||
workspace.installCloseRequestHandler(primaryStage);
|
||||
|
||||
// System-Tray aktivieren: JavaFX-Runtime nicht beenden wenn Fenster versteckt wird
|
||||
Platform.setImplicitExit(false);
|
||||
trayManager = new SystemTrayManager(primaryStage);
|
||||
if (trayManager.install()) {
|
||||
installTrayCloseHandler(primaryStage, workspace);
|
||||
}
|
||||
|
||||
primaryStage.setMaximized(true);
|
||||
primaryStage.show();
|
||||
|
||||
// Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden.
|
||||
workspace.autoLoadLastConfiguration();
|
||||
|
||||
LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the JavaFX runtime when the application is stopping.
|
||||
* <p>
|
||||
* Logs the GUI shutdown event. No additional cleanup is required.
|
||||
* Entfernt das System-Tray-Icon und loggt das Beenden.
|
||||
*/
|
||||
@Override
|
||||
public void stop() {
|
||||
LOG.info("GUI: JavaFX-Anwendung wird beendet.");
|
||||
if (trayManager != null) {
|
||||
trayManager.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
|
||||
* System-Tray minimiert statt es zu schließen.
|
||||
* <p>
|
||||
* Der vom Workspace installierte Handler wird dabei vorrangig aufgerufen. Nur wenn
|
||||
* er das Event nicht konsumiert (sauberer Zustand, keine laufenden Operationen),
|
||||
* greift dieser Handler und versteckt das Fenster.
|
||||
*
|
||||
* @param stage das primäre Fenster
|
||||
* @param workspace der Workspace-Handler, der bereits installiert wurde
|
||||
*/
|
||||
private void installTrayCloseHandler(Stage stage, GuiConfigurationEditorWorkspace workspace) {
|
||||
EventHandler<WindowEvent> workspaceHandler = stage.getOnCloseRequest();
|
||||
stage.setOnCloseRequest(event -> {
|
||||
// Workspace-Handler zuerst: prüft Dirty-State, laufende Operationen usw.
|
||||
if (workspaceHandler != null) {
|
||||
workspaceHandler.handle(event);
|
||||
}
|
||||
// Wurde das Event nicht konsumiert, ist der Zustand sauber: Fenster in Tray verstecken
|
||||
if (!event.isConsumed()) {
|
||||
event.consume();
|
||||
LOG.info("GUI: Fenster wird in den System-Tray minimiert.");
|
||||
stage.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.awt.AWTException;
|
||||
import java.awt.MenuItem;
|
||||
import java.awt.PopupMenu;
|
||||
import java.awt.SystemTray;
|
||||
import java.awt.TrayIcon;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
/**
|
||||
* Verwaltet das Windows System-Tray-Icon für den PDF-Umbenenner.
|
||||
* <p>
|
||||
* Wird das Hauptfenster geschlossen, bleibt die Anwendung im Hintergrund aktiv und zeigt
|
||||
* ein Tray-Icon in der Windows-Taskleiste. Über das Kontextmenü kann das Fenster wieder
|
||||
* geöffnet oder die Anwendung vollständig beendet werden.
|
||||
* <p>
|
||||
* Alle Stage-Operationen werden auf dem JavaFX Application Thread ausgeführt, da AWT-Events
|
||||
* auf dem AWT Event Dispatch Thread eintreffen.
|
||||
*/
|
||||
class SystemTrayManager {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(SystemTrayManager.class);
|
||||
|
||||
private final Stage stage;
|
||||
private TrayIcon trayIcon;
|
||||
private boolean installed;
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen {@code SystemTrayManager} für die angegebene Stage.
|
||||
*
|
||||
* @param stage das primäre Fenster; darf nicht {@code null} sein
|
||||
*/
|
||||
SystemTrayManager(Stage stage) {
|
||||
this.stage = stage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installiert das System-Tray-Icon.
|
||||
* <p>
|
||||
* Schlägt die Installation fehl (System-Tray nicht unterstützt oder Icon-Bild nicht ladbar),
|
||||
* wird {@code false} zurückgegeben und kein Tray-Icon angezeigt.
|
||||
*
|
||||
* @return {@code true} wenn das Icon erfolgreich installiert wurde, sonst {@code false}
|
||||
*/
|
||||
boolean install() {
|
||||
if (!SystemTray.isSupported()) {
|
||||
LOG.warn("GUI: System-Tray wird auf diesem System nicht unterstützt.");
|
||||
return false;
|
||||
}
|
||||
BufferedImage image = loadTrayImage();
|
||||
if (image == null) {
|
||||
return false;
|
||||
}
|
||||
PopupMenu menu = buildContextMenu();
|
||||
trayIcon = new TrayIcon(image, "PDF-Umbenenner", menu);
|
||||
trayIcon.setImageAutoSize(true);
|
||||
// Doppelklick öffnet das Fenster
|
||||
trayIcon.addActionListener(e -> Platform.runLater(this::showWindow));
|
||||
try {
|
||||
SystemTray.getSystemTray().add(trayIcon);
|
||||
installed = true;
|
||||
LOG.info("GUI: System-Tray-Icon erfolgreich installiert.");
|
||||
return true;
|
||||
} catch (AWTException e) {
|
||||
LOG.warn("GUI: System-Tray-Icon konnte nicht installiert werden: {}", e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt das Tray-Icon aus dem System-Tray.
|
||||
* Ist kein Icon installiert, wird der Aufruf ignoriert.
|
||||
*/
|
||||
void remove() {
|
||||
if (installed && trayIcon != null) {
|
||||
SystemTray.getSystemTray().remove(trayIcon);
|
||||
installed = false;
|
||||
LOG.info("GUI: System-Tray-Icon entfernt.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob das Tray-Icon aktiv installiert ist.
|
||||
*
|
||||
* @return {@code true} wenn das Icon im System-Tray sichtbar ist
|
||||
*/
|
||||
boolean isInstalled() {
|
||||
return installed;
|
||||
}
|
||||
|
||||
private BufferedImage loadTrayImage() {
|
||||
try (InputStream stream = getClass().getResourceAsStream("/icons/Icon16.png")) {
|
||||
if (stream == null) {
|
||||
LOG.warn("GUI: Tray-Icon-Ressource '/icons/Icon16.png' nicht gefunden.");
|
||||
return null;
|
||||
}
|
||||
return ImageIO.read(stream);
|
||||
} catch (IOException e) {
|
||||
LOG.warn("GUI: Tray-Icon-Bild konnte nicht geladen werden: {}", e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private PopupMenu buildContextMenu() {
|
||||
PopupMenu menu = new PopupMenu();
|
||||
|
||||
MenuItem openItem = new MenuItem("Öffnen");
|
||||
openItem.addActionListener(e -> Platform.runLater(this::showWindow));
|
||||
|
||||
MenuItem exitItem = new MenuItem("Beenden");
|
||||
exitItem.addActionListener(e -> {
|
||||
remove();
|
||||
Platform.exit();
|
||||
System.exit(0);
|
||||
});
|
||||
|
||||
menu.add(openItem);
|
||||
menu.addSeparator();
|
||||
menu.add(exitItem);
|
||||
return menu;
|
||||
}
|
||||
|
||||
private void showWindow() {
|
||||
stage.show();
|
||||
stage.toFront();
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
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.
|
||||
* <p>
|
||||
* Die Klasse wertet die englischsprachige Fehlermeldung aus dem Verarbeitungsversuch
|
||||
* musterbasiert aus und liefert eine für den Endbenutzer lesbare Beschreibung des
|
||||
* Fehlergrunds. Das ursprüngliche Datenmodell bleibt unverändert; die Übersetzung
|
||||
* findet ausschließlich in der Darstellungsschicht statt.
|
||||
* <p>
|
||||
* Die Mustererkennung erfolgt ohne Berücksichtigung der Groß-/Kleinschreibung
|
||||
* und prüft die definierten Schlüsselbegriffe in festgelegter Reihenfolge,
|
||||
* damit spezifischere Muster vor allgemeineren greifen.
|
||||
*/
|
||||
final class AiFailureMessageTranslator {
|
||||
|
||||
private AiFailureMessageTranslator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert eine benutzerfreundliche deutsche Fehlermeldung für die angegebene
|
||||
* technische Fehlerbeschreibung.
|
||||
* <p>
|
||||
* Ist {@code technicalMessage} {@code null} oder leer, wird der allgemeine
|
||||
* Fallback-Text zurückgegeben.
|
||||
*
|
||||
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
|
||||
* @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol
|
||||
*/
|
||||
static String translate(String technicalMessage) {
|
||||
if (technicalMessage == null || technicalMessage.isBlank()) {
|
||||
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
|
||||
}
|
||||
String lower = technicalMessage.toLowerCase(java.util.Locale.ROOT);
|
||||
|
||||
// Pre-Check-Fehler: kein lesbarer Text im PDF
|
||||
if (lower.contains("no usable text")) {
|
||||
return "PDF enthält keinen lesbaren Text. Möglicherweise handelt es sich um einen Scan"
|
||||
+ " ohne Texterkennung (OCR). Eine automatische Benennung ist nicht möglich.";
|
||||
}
|
||||
|
||||
// KI-Validierungsfehler: Titel überschreitet die konfigurierte Maximallänge
|
||||
if (lower.contains("title exceeds")) {
|
||||
return buildTitleExceedsMessage(technicalMessage);
|
||||
}
|
||||
|
||||
// Defekte oder strukturell nicht lesbare PDF-Datei
|
||||
if (lower.contains("content not extractable")
|
||||
|| lower.contains("ioexception")
|
||||
|| lower.contains("end of file")
|
||||
|| lower.contains("endoffileexception")
|
||||
|| lower.contains("eof")) {
|
||||
return "Die PDF-Datei ist ungültig oder beschädigt und kann nicht verarbeitet werden.";
|
||||
}
|
||||
|
||||
// HTTP-Authentifizierungsfehler
|
||||
if (lower.contains("http_401")) {
|
||||
return "KI-Dienst: Ungültiger API-Schlüssel. Bitte in den Einstellungen prüfen.";
|
||||
}
|
||||
if (lower.contains("http_403")) {
|
||||
return "KI-Dienst: Zugriff verweigert. Bitte API-Schlüssel und Berechtigungen prüfen.";
|
||||
}
|
||||
if (lower.contains("http_429")) {
|
||||
return "KI-Dienst: Anfragelimit erreicht. Bitte später erneut versuchen.";
|
||||
}
|
||||
if (lower.contains("http_5")) {
|
||||
return "KI-Dienst vorübergehend nicht erreichbar. Bitte später erneut versuchen.";
|
||||
}
|
||||
|
||||
// Netzwerk- und Verbindungsfehler
|
||||
if (lower.contains("connection") || lower.contains("timeout") || lower.contains("refused")) {
|
||||
return "KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen.";
|
||||
}
|
||||
|
||||
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus einer „Title exceeds"-Fehlermeldung einen benutzerfreundlichen Text,
|
||||
* der Titel, tatsächliche Länge und konfiguriertes Limit nennt.
|
||||
* <p>
|
||||
* Erwartet wird das Format:
|
||||
* {@code … Title exceeds N characters (base title): 'Titel' …}
|
||||
* <p>
|
||||
* Kann das Format nicht geparst werden, wird ein generischer Hinweis zurückgegeben.
|
||||
*
|
||||
* @param technicalMessage die vollständige technische Fehlermeldung
|
||||
* @return benutzerfreundlicher Hinweis auf den zu langen Titel
|
||||
*/
|
||||
private static String buildTitleExceedsMessage(String technicalMessage) {
|
||||
try {
|
||||
int exceedsIdx = technicalMessage.indexOf("Title exceeds ");
|
||||
if (exceedsIdx >= 0) {
|
||||
String afterExceeds = technicalMessage.substring(exceedsIdx + "Title exceeds ".length());
|
||||
int charIdx = afterExceeds.indexOf(" characters");
|
||||
if (charIdx > 0) {
|
||||
int limit = Integer.parseInt(afterExceeds.substring(0, charIdx).trim());
|
||||
int colonQuote = technicalMessage.indexOf(": '", exceedsIdx);
|
||||
if (colonQuote >= 0) {
|
||||
String afterQuote = technicalMessage.substring(colonQuote + 3);
|
||||
int closingQuote = afterQuote.lastIndexOf("'");
|
||||
if (closingQuote > 0) {
|
||||
String title = afterQuote.substring(0, closingQuote);
|
||||
return "KI-Vorschlag abgelehnt: '" + title + "' ist zu lang ("
|
||||
+ title.length() + " Zeichen, Limit: " + limit
|
||||
+ "). Bitte Dateinamen manuell kürzen.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException | StringIndexOutOfBoundsException ignored) {
|
||||
// Fallback unten
|
||||
}
|
||||
return "KI-Vorschlag abgelehnt: Titel überschreitet die maximale Länge. Bitte Dateinamen manuell kürzen.";
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
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.application.Platform;
|
||||
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);
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.util.Duration;
|
||||
|
||||
/**
|
||||
* Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
|
||||
* Ergebnis-Zeile.
|
||||
* <p>
|
||||
* Die Komponente kapselt Eingabefeld, Validierungsanzeige sowie die
|
||||
* Schaltflächen „Dateiname übernehmen" und „Zurücksetzen auf KI-Vorschlag". Sie kennt
|
||||
* drei Zustände gemäß fachlicher Spezifikation:
|
||||
* <ul>
|
||||
* <li><b>KI-Vorschlag</b> – der ursprünglich generierte Name; unveränderlich pro Zeile.</li>
|
||||
* <li><b>Letzter gespeicherter Name</b> – der zuletzt bestätigte Name; entspricht dem
|
||||
* aktuellen Stand in Dateisystem und Persistenz.</li>
|
||||
* <li><b>Aktuelle Eingabe</b> – der im Textfeld sichtbare Wert; kann vom letzten
|
||||
* gespeicherten Namen abweichen (Dirty-State).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* <p>
|
||||
* Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen werden.
|
||||
* Die tatsächliche Speicher-Operation ist in der Verantwortung des aufrufenden Tabs und
|
||||
* läuft dort auf einem Hintergrund-Worker-Thread.
|
||||
*/
|
||||
public final class FileNameEditorPane {
|
||||
|
||||
/** Feste PDF-Erweiterung für Zieldateien. */
|
||||
public static final String PDF_EXTENSION = ".pdf";
|
||||
|
||||
/** Windows-Maximal-Pfadlänge (MAX_PATH = 260 inkl. Null-Terminator = 259 nutzbar). */
|
||||
public static final int MAX_WINDOWS_PATH_LENGTH = 259;
|
||||
|
||||
private static final Set<String> RESERVED_WINDOWS_NAMES = buildReservedWindowsNames();
|
||||
private static final String FORBIDDEN_CHARS_REGEX = ".*[\\\\/:*?\"<>|].*";
|
||||
|
||||
private final VBox root = new VBox(4);
|
||||
private final TextField textField = new TextField();
|
||||
private final Label validationLabel = new Label();
|
||||
private final Button saveButton = new Button("Dateiname übernehmen");
|
||||
private final Button resetButton = new Button("Zurücksetzen auf KI-Vorschlag");
|
||||
private final Label sectionTitle = new Label("Dateiname");
|
||||
|
||||
private Optional<String> aiProposal = Optional.empty();
|
||||
private Optional<String> lastSavedName = Optional.empty();
|
||||
private String targetFolderPath = "";
|
||||
private boolean selectionEditable = false;
|
||||
private boolean globalEnabled = true;
|
||||
private boolean suppressValidation = false;
|
||||
|
||||
private Consumer<String> onSaveRequested = name -> { };
|
||||
|
||||
/**
|
||||
* Erstellt die Komponente mit leerem und deaktiviertem Zustand.
|
||||
*/
|
||||
public FileNameEditorPane() {
|
||||
sectionTitle.setStyle("-fx-font-weight: bold;");
|
||||
|
||||
textField.setId("filename-editor-text-field");
|
||||
HBox.setHgrow(textField, Priority.ALWAYS);
|
||||
|
||||
HBox inputRow = new HBox(4, textField);
|
||||
inputRow.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
validationLabel.setId("filename-editor-validation-label");
|
||||
validationLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #c62828;");
|
||||
validationLabel.setVisible(false);
|
||||
validationLabel.setManaged(false);
|
||||
validationLabel.setWrapText(true);
|
||||
|
||||
saveButton.setId("filename-editor-save-button");
|
||||
saveButton.setOnAction(e -> fireSaveRequest());
|
||||
Tooltip saveTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
|
||||
saveTooltip.setShowDelay(Duration.millis(300));
|
||||
saveButton.setTooltip(saveTooltip);
|
||||
|
||||
resetButton.setId("filename-editor-reset-button");
|
||||
resetButton.setOnAction(e -> resetToAiProposal());
|
||||
Tooltip resetTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
|
||||
resetTooltip.setShowDelay(Duration.millis(300));
|
||||
resetButton.setTooltip(resetTooltip);
|
||||
|
||||
HBox buttonRow = new HBox(8, saveButton, resetButton);
|
||||
buttonRow.setAlignment(Pos.CENTER_LEFT);
|
||||
buttonRow.setPadding(new Insets(4, 0, 0, 0));
|
||||
|
||||
root.getChildren().addAll(sectionTitle, inputRow, validationLabel, buttonRow);
|
||||
root.setPadding(new Insets(0, 0, 4, 0));
|
||||
|
||||
// Live-Validierung auf jeden Tastendruck.
|
||||
textField.textProperty().addListener((obs, oldText, newText) -> {
|
||||
if (!suppressValidation) {
|
||||
refreshUiState();
|
||||
}
|
||||
});
|
||||
|
||||
// Enter löst Speichern aus, Escape setzt auf lastSavedName zurück.
|
||||
textField.setOnKeyPressed(event -> {
|
||||
if (event.getCode() == KeyCode.ENTER) {
|
||||
if (!saveButton.isDisabled()) {
|
||||
fireSaveRequest();
|
||||
event.consume();
|
||||
}
|
||||
} else if (event.getCode() == KeyCode.ESCAPE) {
|
||||
discardChanges();
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
|
||||
*
|
||||
* @return das Root-Control der Komponente; nie null
|
||||
*/
|
||||
public Region getNode() {
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert einen Callback, der ausgelöst wird, wenn der Benutzer „Dateiname übernehmen"
|
||||
* anfordert. Parameter ist der gewünschte Basisname ohne {@code .pdf}-Erweiterung.
|
||||
*
|
||||
* @param callback Callback; darf nicht null sein (leerer Consumer als No-Op möglich)
|
||||
*/
|
||||
public void setOnSaveRequested(Consumer<String> callback) {
|
||||
this.onSaveRequested = Objects.requireNonNull(callback, "callback must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert den Zustand für die neu selektierte Zeile.
|
||||
* <p>
|
||||
* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet,
|
||||
* der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}.
|
||||
* Editierbarkeitsregeln:
|
||||
* <ul>
|
||||
* <li>{@code resetPending} → nicht editierbar.</li>
|
||||
* <li>{@code SUCCESS} und {@code SKIPPED_ALREADY_PROCESSED} → editierbar, sofern
|
||||
* ein bisher gespeicherter Zieldateiname vorliegt (Umbenennen einer existierenden
|
||||
* Zieldatei).</li>
|
||||
* <li>{@code FAILED_RETRYABLE}, {@code FAILED_PERMANENT} und
|
||||
* {@code SKIPPED_FINAL_FAILURE} → editierbar; das Eingabefeld erlaubt die
|
||||
* Eingabe eines manuellen Zieldateinamens auch dann, wenn (noch) kein
|
||||
* Vorschlag oder gespeicherter Name vorliegt (Kopieren der Quelldatei
|
||||
* mit manuellem Namen).</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()}
|
||||
* @param targetFolderPath Zielordner-Pfad für die Pfadlängen-Validierung; darf
|
||||
* {@code null} sein (wird als leer behandelt)
|
||||
*/
|
||||
public void loadSelection(GuiBatchRunResultRow row, String targetFolderPath) {
|
||||
this.targetFolderPath = targetFolderPath == null ? "" : targetFolderPath;
|
||||
if (row == null) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
this.aiProposal = stripPdfExtension(row.finalFileName());
|
||||
this.lastSavedName = stripPdfExtension(row.effectiveFileName());
|
||||
|
||||
boolean editable;
|
||||
if (row.resetPending()) {
|
||||
editable = false;
|
||||
} else if (requiresExistingTargetForRename(row.status())) {
|
||||
// Umbenennen einer existierenden Zieldatei: nur sinnvoll, wenn ein
|
||||
// gespeicherter Name vorliegt.
|
||||
editable = lastSavedName.isPresent();
|
||||
} else {
|
||||
// Manuelle Kopie: das Feld ist auch ohne gespeicherten Namen editierbar.
|
||||
editable = isRowEditable(row);
|
||||
}
|
||||
this.selectionEditable = editable;
|
||||
|
||||
suppressValidation = true;
|
||||
try {
|
||||
textField.setText(lastSavedName.orElse(""));
|
||||
} finally {
|
||||
suppressValidation = false;
|
||||
}
|
||||
refreshUiState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert {@code true}, wenn die Zeile einen Status hat, bei dem die Editierung
|
||||
* eine bestehende Zieldatei umbenennt (im Gegensatz zur Kopie der Quelldatei).
|
||||
*
|
||||
* @param status der aggregierte Abschlussstatus der Zeile
|
||||
* @return {@code true} für SUCCESS und SKIPPED_ALREADY_PROCESSED; sonst {@code false}
|
||||
*/
|
||||
private static boolean requiresExistingTargetForRename(DocumentCompletionStatus status) {
|
||||
return status == DocumentCompletionStatus.SUCCESS
|
||||
|| status == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leert die Komponente und deaktiviert die Eingabe. Wird aufgerufen wenn keine Zeile
|
||||
* selektiert ist.
|
||||
*/
|
||||
public void clearSelection() {
|
||||
this.aiProposal = Optional.empty();
|
||||
this.lastSavedName = Optional.empty();
|
||||
this.selectionEditable = false;
|
||||
suppressValidation = true;
|
||||
try {
|
||||
textField.setText("");
|
||||
} finally {
|
||||
suppressValidation = false;
|
||||
}
|
||||
refreshUiState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Textfeldinhalt auf den zuletzt gespeicherten Namen zurück. Äquivalent zum
|
||||
* Drücken der Escape-Taste im Textfeld.
|
||||
*/
|
||||
public void discardChanges() {
|
||||
suppressValidation = true;
|
||||
try {
|
||||
textField.setText(lastSavedName.orElse(""));
|
||||
} finally {
|
||||
suppressValidation = false;
|
||||
}
|
||||
refreshUiState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Textfeldinhalt auf den KI-Vorschlag zurück. Es erfolgt <em>kein</em>
|
||||
* Speichervorgang – der Benutzer kann anschließend über „Dateiname übernehmen"
|
||||
* bestätigen.
|
||||
*/
|
||||
public void resetToAiProposal() {
|
||||
if (aiProposal.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
suppressValidation = true;
|
||||
try {
|
||||
textField.setText(aiProposal.get());
|
||||
} finally {
|
||||
suppressValidation = false;
|
||||
}
|
||||
refreshUiState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktiviert oder deaktiviert die gesamte Komponente. Während eines laufenden Batch-Laufs
|
||||
* soll die Komponente deaktiviert sein.
|
||||
*
|
||||
* @param enabled {@code true} wenn Bedienung erlaubt ist
|
||||
*/
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.globalEnabled = enabled;
|
||||
refreshUiState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert {@code true} wenn die aktuelle Texteingabe vom letzten gespeicherten Namen
|
||||
* abweicht.
|
||||
*
|
||||
* @return ob ungespeicherte Änderungen im Textfeld vorliegen
|
||||
*/
|
||||
public boolean isDirty() {
|
||||
if (!selectionEditable) {
|
||||
return false;
|
||||
}
|
||||
String current = textField.getText() == null ? "" : textField.getText();
|
||||
String saved = lastSavedName.orElse("");
|
||||
return !current.equals(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Dirty-State zurück, ohne das Textfeld neu zu laden. Wird aufgerufen,
|
||||
* nachdem eine Umbenennung erfolgreich abgeschlossen wurde, damit ein anschließendes
|
||||
* Ersetzen der Tabellenzeile keinen Verwerfen-Dialog auslöst. Der angezeigte Text
|
||||
* im Textfeld bleibt unverändert; {@code lastSavedName} wird auf den aktuellen
|
||||
* Textfeldinhalt gesetzt.
|
||||
*/
|
||||
public void clearDirtyState() {
|
||||
String current = textField.getText() == null ? "" : textField.getText();
|
||||
this.lastSavedName = current.isBlank() ? Optional.empty() : Optional.of(current);
|
||||
refreshUiState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert {@code true} wenn für die aktuelle Zeile ein KI-Vorschlag vorliegt.
|
||||
*
|
||||
* @return ob ein KI-Vorschlag existiert
|
||||
*/
|
||||
public boolean hasAiProposal() {
|
||||
return aiProposal.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert {@code true} wenn für die aktuelle Zeile ein zuletzt gespeicherter Name
|
||||
* existiert.
|
||||
*
|
||||
* @return ob ein letzter gespeicherter Name existiert
|
||||
*/
|
||||
public boolean hasLastSaved() {
|
||||
return lastSavedName.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert intern den letzten gespeicherten Namen. Typisch nach erfolgreichem
|
||||
* Speichervorgang im Tab (ohne erneut {@link #loadSelection(GuiBatchRunResultRow, String)}
|
||||
* aufzurufen).
|
||||
*
|
||||
* @param newLastSavedName neuer letzter gespeicherter Name ohne {@code .pdf}; darf
|
||||
* {@code null} sein
|
||||
*/
|
||||
public void updateLastSavedName(String newLastSavedName) {
|
||||
this.lastSavedName = newLastSavedName == null || newLastSavedName.isBlank()
|
||||
? Optional.empty()
|
||||
: Optional.of(newLastSavedName);
|
||||
suppressValidation = true;
|
||||
try {
|
||||
textField.setText(lastSavedName.orElse(""));
|
||||
} finally {
|
||||
suppressValidation = false;
|
||||
}
|
||||
refreshUiState();
|
||||
}
|
||||
|
||||
// --- Test-Accessoren ------------------------------------------------------
|
||||
|
||||
/** Visible for tests. */
|
||||
TextField textField() {
|
||||
return textField;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
Label validationLabel() {
|
||||
return validationLabel;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
Button saveButton() {
|
||||
return saveButton;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
Button resetButton() {
|
||||
return resetButton;
|
||||
}
|
||||
|
||||
// --- Interne Helfer -------------------------------------------------------
|
||||
|
||||
private void fireSaveRequest() {
|
||||
if (saveButton.isDisabled()) {
|
||||
return;
|
||||
}
|
||||
String current = textField.getText() == null ? "" : textField.getText();
|
||||
onSaveRequested.accept(current);
|
||||
}
|
||||
|
||||
private void refreshUiState() {
|
||||
boolean enabled = selectionEditable && globalEnabled;
|
||||
textField.setDisable(!enabled);
|
||||
// Button „Zurücksetzen auf KI-Vorschlag" ist nur aktiv, wenn Eingabe möglich
|
||||
// und ein KI-Vorschlag vorliegt.
|
||||
resetButton.setDisable(aiProposal.isEmpty() || !enabled);
|
||||
|
||||
if (!enabled) {
|
||||
// Validierung und Speichern-Button unterdrücken, Rahmen neutral.
|
||||
validationLabel.setVisible(false);
|
||||
validationLabel.setManaged(false);
|
||||
textField.setStyle("");
|
||||
saveButton.setDisable(true);
|
||||
return;
|
||||
}
|
||||
|
||||
String current = textField.getText() == null ? "" : textField.getText();
|
||||
Optional<String> error = validate(current);
|
||||
|
||||
if (error.isPresent()) {
|
||||
validationLabel.setText(error.get());
|
||||
validationLabel.setVisible(true);
|
||||
validationLabel.setManaged(true);
|
||||
textField.setStyle("-fx-border-color: #c62828; -fx-border-width: 1.5;");
|
||||
saveButton.setDisable(true);
|
||||
} else {
|
||||
validationLabel.setVisible(false);
|
||||
validationLabel.setManaged(false);
|
||||
if (isDirty()) {
|
||||
// Dirty-Markierung: orangefarbener Rand.
|
||||
textField.setStyle("-fx-border-color: #e65100; -fx-border-width: 1.5;");
|
||||
saveButton.setDisable(false);
|
||||
} else {
|
||||
textField.setStyle("");
|
||||
saveButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt die vollständige Dateinamen-Validierung aus und liefert gegebenenfalls den
|
||||
* fachlichen Fehlertext. Paket-privat für Unit-Tests.
|
||||
*
|
||||
* @param input Eingabe aus dem Textfeld (ohne {@code .pdf})
|
||||
* @return der Fehlertext oder {@link Optional#empty()} wenn gültig
|
||||
*/
|
||||
Optional<String> validate(String input) {
|
||||
if (input == null || input.isBlank()) {
|
||||
return Optional.of("Dateiname darf nicht leer sein");
|
||||
}
|
||||
if (!input.equals(input.strip())) {
|
||||
return Optional.of("Leerzeichen am Anfang oder Ende nicht erlaubt");
|
||||
}
|
||||
if (input.matches(FORBIDDEN_CHARS_REGEX)) {
|
||||
return Optional.of("Unerlaubtes Zeichen (nicht erlaubt: \\ / : * ? \" < > |)");
|
||||
}
|
||||
if (RESERVED_WINDOWS_NAMES.contains(input.toUpperCase(java.util.Locale.ROOT))) {
|
||||
return Optional.of("Reservierter Systemname");
|
||||
}
|
||||
if (input.endsWith(".")) {
|
||||
return Optional.of("Dateiname darf nicht auf einen Punkt enden");
|
||||
}
|
||||
int totalLength = pathLengthEstimate(input);
|
||||
if (totalLength > MAX_WINDOWS_PATH_LENGTH) {
|
||||
return Optional.of("Dateipfad zu lang (Windows-Limit " + MAX_WINDOWS_PATH_LENGTH
|
||||
+ " Zeichen, aktuell " + totalLength + ")");
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private int pathLengthEstimate(String baseName) {
|
||||
String folder = targetFolderPath == null ? "" : targetFolderPath;
|
||||
int folderLength = folder.length();
|
||||
int separatorLength = folderLength == 0 ? 0 : 1;
|
||||
return folderLength + separatorLength + baseName.length() + PDF_EXTENSION.length();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert {@code true}, wenn die Zeile fachlich für eine manuelle Dateinamens-Aktion
|
||||
* editierbar ist.
|
||||
* <p>
|
||||
* Editierbar sind alle nicht-resetpending-Zeilen unabhängig davon, ob die Aktion
|
||||
* eine Zieldatei umbenennt (SUCCESS, SKIPPED_ALREADY_PROCESSED) oder die Quelldatei
|
||||
* kopiert (FAILED_*, SKIPPED_FINAL_FAILURE). Die genaue Aktion wird vom Tab anhand
|
||||
* des Status entschieden.
|
||||
*
|
||||
* @param row die Zeile, deren Editierbarkeit geprüft werden soll
|
||||
* @return {@code true} wenn die Zeile editierbar ist; sonst {@code false}
|
||||
*/
|
||||
private static boolean isRowEditable(GuiBatchRunResultRow row) {
|
||||
return !row.resetPending();
|
||||
}
|
||||
|
||||
private static Optional<String> stripPdfExtension(Optional<String> fileNameWithExtension) {
|
||||
if (fileNameWithExtension.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String raw = fileNameWithExtension.get();
|
||||
if (raw.toLowerCase(java.util.Locale.ROOT).endsWith(PDF_EXTENSION)) {
|
||||
return Optional.of(raw.substring(0, raw.length() - PDF_EXTENSION.length()));
|
||||
}
|
||||
return Optional.of(raw);
|
||||
}
|
||||
|
||||
private static Set<String> buildReservedWindowsNames() {
|
||||
Set<String> reserved = new HashSet<>();
|
||||
reserved.add("CON");
|
||||
reserved.add("PRN");
|
||||
reserved.add("AUX");
|
||||
reserved.add("NUL");
|
||||
for (int i = 1; i <= 9; i++) {
|
||||
reserved.add("COM" + i);
|
||||
reserved.add("LPT" + i);
|
||||
}
|
||||
return Set.copyOf(reserved);
|
||||
}
|
||||
}
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
import javafx.application.Platform;
|
||||
|
||||
/**
|
||||
* Coordinates a single batch run (regular or targeted mini-run) triggered from the
|
||||
* JavaFX GUI, and optional reset-only operations on selected document fingerprints.
|
||||
* <p>
|
||||
* The coordinator owns the background worker thread that executes the run, maintains the
|
||||
* cancellation flag, and translates the
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
|
||||
* callbacks into a GUI-friendly event stream on the JavaFX Application Thread.
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* <ul>
|
||||
* <li>The batch run and reset operations execute on a daemon worker thread created by
|
||||
* {@link #threadFactory}. No JavaFX code touches this thread.</li>
|
||||
* <li>Every GUI callback ({@link Listener}) is invoked on the JavaFX Application Thread
|
||||
* via {@link Platform#runLater(Runnable)}, so listeners may freely mutate
|
||||
* {@code Control}s without taking any further precautions.</li>
|
||||
* <li>{@link #requestCancellation()} sets a volatile flag that the use case polls
|
||||
* between candidates (soft-stop). It never interrupts the worker thread; the
|
||||
* currently-processed candidate always completes in full.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Lifecycle</h2>
|
||||
* <ol>
|
||||
* <li>Construct with a regular launcher, a mini-run launcher, a reset port, a thread
|
||||
* factory and a listener.</li>
|
||||
* <li>Call {@link #start(Path)} to begin a regular run, or
|
||||
* {@link #startMiniRun(Path, Set)} for a targeted mini-run, or
|
||||
* {@link #startReset(Path, Set)} for a status-reset-only operation.</li>
|
||||
* <li>Optionally call {@link #requestCancellation()} to trigger soft-stop for runs.</li>
|
||||
* <li>Wait for {@link Listener#onRunEnded(RunSummary, GuiBatchRunLaunchOutcome)} or
|
||||
* {@link Listener#onResetCompleted(ResetDocumentStatusResult)} on the FX thread.</li>
|
||||
* <li>Start a new operation only after the previous one has ended.</li>
|
||||
* </ol>
|
||||
*/
|
||||
public final class GuiBatchRunCoordinator {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
|
||||
private static final String WORKER_THREAD_NAME = "gui-batch-run";
|
||||
|
||||
/**
|
||||
* Listener interface invoked on the JavaFX Application Thread during a run or reset.
|
||||
*/
|
||||
public interface Listener {
|
||||
|
||||
/**
|
||||
* Invoked once, after the batch use case has scanned the source folder and knows
|
||||
* the total candidate count.
|
||||
*
|
||||
* @param runId the identifier of the run; never {@code null}
|
||||
* @param totalCandidates the number of candidates detected in the source folder;
|
||||
* never negative
|
||||
*/
|
||||
void onRunStarted(RunId runId, int totalCandidates);
|
||||
|
||||
/**
|
||||
* Invoked once per candidate whose processing reached a terminal resolution.
|
||||
*
|
||||
* @param row the row describing the candidate result; never {@code null}
|
||||
*/
|
||||
void onDocumentCompleted(GuiBatchRunResultRow row);
|
||||
|
||||
/**
|
||||
* Invoked once after the run has fully terminated on the worker thread.
|
||||
*
|
||||
* @param summary the final outcome counts; never {@code null}
|
||||
* @param outcome a description of how the run terminated; never {@code null}
|
||||
*/
|
||||
void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);
|
||||
|
||||
/**
|
||||
* Invoked once after a reset-only operation has completed on the worker thread.
|
||||
* <p>
|
||||
* The default implementation does nothing so existing {@link Listener}
|
||||
* implementations need not override this method until they need reset
|
||||
* notifications.
|
||||
*
|
||||
* @param result the full outcome of the reset operation; never {@code null}
|
||||
*/
|
||||
default void onResetCompleted(ResetDocumentStatusResult result) {
|
||||
// no-op default
|
||||
}
|
||||
}
|
||||
|
||||
private final GuiBatchRunLauncher launcher;
|
||||
private final GuiMiniRunLauncher miniRunLauncher;
|
||||
private final GuiResetDocumentStatusPort resetPort;
|
||||
private final Function<Runnable, Thread> threadFactory;
|
||||
private final Consumer<Runnable> fxDispatcher;
|
||||
private final Listener listener;
|
||||
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
||||
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
|
||||
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
|
||||
|
||||
/**
|
||||
* Creates the coordinator with the default worker-thread factory and the default
|
||||
* JavaFX Application Thread dispatcher.
|
||||
* <p>
|
||||
* Mini-run and reset capabilities are unavailable; all such requests will return
|
||||
* {@code false}.
|
||||
*
|
||||
* @param launcher bridge to Bootstrap used to execute the batch; must not be null
|
||||
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||
*/
|
||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, Listener listener) {
|
||||
this(launcher,
|
||||
rejectingMiniRunLauncher(),
|
||||
rejectingResetPort(),
|
||||
defaultThreadFactory(),
|
||||
defaultFxDispatcher(),
|
||||
listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the coordinator with all ports and the default worker-thread factory and
|
||||
* JavaFX Application Thread dispatcher.
|
||||
*
|
||||
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||
* not be null
|
||||
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||
*/
|
||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||
GuiMiniRunLauncher miniRunLauncher,
|
||||
GuiResetDocumentStatusPort resetPort,
|
||||
Listener listener) {
|
||||
this(launcher, miniRunLauncher, resetPort,
|
||||
defaultThreadFactory(), defaultFxDispatcher(), listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the coordinator with all ports and the historical file name port, using the
|
||||
* default worker-thread factory and JavaFX Application Thread dispatcher.
|
||||
*
|
||||
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||
* not be null
|
||||
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
|
||||
* skipped documents; must not be null
|
||||
*/
|
||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||
GuiMiniRunLauncher miniRunLauncher,
|
||||
GuiResetDocumentStatusPort resetPort,
|
||||
Listener listener,
|
||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
||||
this(launcher, miniRunLauncher, resetPort,
|
||||
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the coordinator with custom hooks for the worker-thread factory and the
|
||||
* UI-thread dispatcher.
|
||||
* <p>
|
||||
* Tests use this constructor to execute batches synchronously or to verify which
|
||||
* thread UI callbacks run on, without depending on an actual JavaFX runtime being
|
||||
* initialised.
|
||||
*
|
||||
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||
* not be null
|
||||
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||
* be null
|
||||
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
|
||||
* Thread; must not be null
|
||||
* @param listener GUI listener; must not be null
|
||||
*/
|
||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||
GuiMiniRunLauncher miniRunLauncher,
|
||||
GuiResetDocumentStatusPort resetPort,
|
||||
Function<Runnable, Thread> threadFactory,
|
||||
Consumer<Runnable> fxDispatcher,
|
||||
Listener listener) {
|
||||
this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
|
||||
noOpHistoricalDocumentContextPort());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the coordinator with all ports, custom thread factory, FX dispatcher and
|
||||
* historical file name port.
|
||||
* <p>
|
||||
* This is the canonical constructor. All other constructors delegate here.
|
||||
*
|
||||
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||
* not be null
|
||||
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||
* be null
|
||||
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
|
||||
* Thread; must not be null
|
||||
* @param listener GUI listener; must not be null
|
||||
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
|
||||
* skipped documents; must not be null
|
||||
*/
|
||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||
GuiMiniRunLauncher miniRunLauncher,
|
||||
GuiResetDocumentStatusPort resetPort,
|
||||
Function<Runnable, Thread> threadFactory,
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy constructor retained for backward compatibility with tests that do not
|
||||
* require mini-run or reset capabilities.
|
||||
*
|
||||
* @param launcher bridge to Bootstrap; must not be null
|
||||
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||
* be null
|
||||
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
|
||||
* Thread; must not be null
|
||||
* @param listener GUI listener; must not be null
|
||||
*/
|
||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||
Function<Runnable, Thread> threadFactory,
|
||||
Consumer<Runnable> fxDispatcher,
|
||||
Listener listener) {
|
||||
this(launcher,
|
||||
rejectingMiniRunLauncher(),
|
||||
rejectingResetPort(),
|
||||
threadFactory,
|
||||
fxDispatcher,
|
||||
listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a run or reset is currently active.
|
||||
*
|
||||
* @return {@code true} while a worker thread is executing
|
||||
*/
|
||||
public boolean isRunning() {
|
||||
Thread worker = activeWorker.get();
|
||||
return worker != null && worker.isAlive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a new regular run for the supplied configuration file.
|
||||
* <p>
|
||||
* Immediately returns once the worker thread has been started. All further progress
|
||||
* is communicated through the configured {@link Listener} on the JavaFX Application
|
||||
* Thread. An attempt to start a new run while another is still active is rejected
|
||||
* with {@code false} and leaves the currently running batch untouched.
|
||||
*
|
||||
* @param configFilePath the configuration file the run shall read from; must not be
|
||||
* {@code null}
|
||||
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||
* was already in progress
|
||||
* @throws NullPointerException if {@code configFilePath} is {@code null}
|
||||
*/
|
||||
public boolean start(Path configFilePath) {
|
||||
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
}
|
||||
cancellationRequested.set(false);
|
||||
Runnable task = () -> executeRun(configFilePath);
|
||||
return startWorker(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a targeted mini-run for the supplied fingerprint filter.
|
||||
* <p>
|
||||
* The worker thread first delegates to the {@link GuiMiniRunLauncher} which applies
|
||||
* the full processing pipeline to only the specified documents. Progress callbacks
|
||||
* are forwarded to the {@link Listener} on the JavaFX Application Thread in the same
|
||||
* way as for a regular run.
|
||||
*
|
||||
* @param configFilePath the configuration file; must not be {@code null}
|
||||
* @param fingerprintFilter the set of document fingerprints to process; must not be
|
||||
* {@code null}
|
||||
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||
* was already in progress
|
||||
* @throws NullPointerException if any argument is {@code null}
|
||||
*/
|
||||
public boolean startMiniRun(Path configFilePath,
|
||||
Set<DocumentFingerprint> fingerprintFilter) {
|
||||
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
}
|
||||
cancellationRequested.set(false);
|
||||
Runnable task = () -> executeMiniRun(configFilePath, fingerprintFilter);
|
||||
return startWorker(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a reprocessing operation: resets the database status of the specified
|
||||
* fingerprints and immediately launches a targeted mini-run for them.
|
||||
* <p>
|
||||
* This method is the preferred entry point for "Erneut verarbeiten" (reprocess)
|
||||
* actions in the GUI. It ensures that documents marked as FAILED_FINAL or otherwise
|
||||
* ineligible for processing are reset before the mini-run begins, so they are
|
||||
* reprocessed rather than skipped.
|
||||
* <p>
|
||||
* The reset executes synchronously on the caller's thread before the worker thread
|
||||
* is started. This guarantees that the mini-run sees the documents in a
|
||||
* reprocessable state.
|
||||
*
|
||||
* @param configFilePath the configuration file; must not be {@code null}
|
||||
* @param fingerprintFilter the set of document fingerprints to reset and process;
|
||||
* must not be {@code null}
|
||||
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||
* was already in progress or when the reset failed for all fingerprints
|
||||
* @throws NullPointerException if any argument is {@code null}
|
||||
*/
|
||||
public boolean startReprocessing(Path configFilePath,
|
||||
Set<DocumentFingerprint> fingerprintFilter) {
|
||||
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
}
|
||||
// Reset the database status synchronously before starting the mini-run.
|
||||
// This ensures that documents are not skipped due to FAILED_FINAL or other
|
||||
// terminal states.
|
||||
LOG.info("GUI-Erneut-Verarbeiten: Starte Status-Reset für {} Dokument(e), Konfiguration={}.",
|
||||
fingerprintFilter.size(), configFilePath);
|
||||
ResetDocumentStatusResult resetResult = resetPort.reset(configFilePath, fingerprintFilter);
|
||||
LOG.info("GUI-Erneut-Verarbeiten: Status-Reset abgeschlossen – {} erfolgreich, {} fehlgeschlagen.",
|
||||
resetResult.successCount(), resetResult.failureCount());
|
||||
if (resetResult.successCount() == 0) {
|
||||
LOG.warn("GUI-Reprocessing: Reset für alle {} Dokumente fehlgeschlagen; "
|
||||
+ "Mini-Lauf wird nicht gestartet.", fingerprintFilter.size());
|
||||
return false;
|
||||
}
|
||||
LOG.info("GUI-Reprocessing: {} von {} Dokumenten erfolgreich zurückgesetzt.",
|
||||
resetResult.successCount(), resetResult.requestedCount());
|
||||
// Now start the mini-run with the reset fingerprints.
|
||||
return startMiniRun(configFilePath, fingerprintFilter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a reset-only operation for the supplied fingerprint set.
|
||||
* <p>
|
||||
* The worker thread calls the {@link GuiResetDocumentStatusPort} to delete all
|
||||
* persistence data for the specified fingerprints. No reprocessing run is triggered.
|
||||
* On completion the {@link Listener#onResetCompleted(ResetDocumentStatusResult)} callback
|
||||
* is invoked on the JavaFX Application Thread.
|
||||
*
|
||||
* @param configFilePath the configuration file that identifies the database; must not
|
||||
* be {@code null}
|
||||
* @param fingerprints the set of document fingerprints to reset; must not be
|
||||
* {@code null}
|
||||
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||
* was already in progress
|
||||
* @throws NullPointerException if any argument is {@code null}
|
||||
*/
|
||||
public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
||||
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
}
|
||||
// Reset does not support cancellation; set the flag to false so the
|
||||
// running state is consistent with the pattern used by run operations.
|
||||
cancellationRequested.set(false);
|
||||
Runnable task = () -> executeReset(configFilePath, fingerprints);
|
||||
return startWorker(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests soft-stop cancellation of the currently running batch or mini-run.
|
||||
* <p>
|
||||
* The flag is honoured between candidates — the candidate that is currently being
|
||||
* processed is always completed in full and persisted before the run ends. Calling
|
||||
* this method when no run is active has no effect. Reset operations ignore this flag.
|
||||
*/
|
||||
public void requestCancellation() {
|
||||
if (isRunning()) {
|
||||
cancellationRequested.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether cancellation has been requested for the current (or last) run.
|
||||
*
|
||||
* @return {@code true} when a cancellation request is pending or was pending when
|
||||
* the last run ended; {@code false} before the first run
|
||||
*/
|
||||
public boolean isCancellationRequested() {
|
||||
return cancellationRequested.get();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Worker helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private boolean startWorker(Runnable task) {
|
||||
Thread worker = threadFactory.apply(task);
|
||||
Objects.requireNonNull(worker, "threadFactory must not return null");
|
||||
activeWorker.set(worker);
|
||||
worker.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void executeRun(Path configFilePath) {
|
||||
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
|
||||
configFilePath);
|
||||
observerSummary.set(null);
|
||||
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
|
||||
BatchRunCancellationToken token = cancellationRequested::get;
|
||||
GuiBatchRunLaunchOutcome outcome;
|
||||
try {
|
||||
outcome = launcher.launch(configFilePath, observer, token);
|
||||
if (outcome == null) {
|
||||
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||
"Launcher hat kein Ergebnis geliefert.");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
|
||||
e.getMessage(), e);
|
||||
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||
"Unerwarteter technischer Fehler: "
|
||||
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||
}
|
||||
finishRun(outcome);
|
||||
}
|
||||
|
||||
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);
|
||||
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
|
||||
BatchRunCancellationToken token = cancellationRequested::get;
|
||||
GuiBatchRunLaunchOutcome outcome;
|
||||
try {
|
||||
outcome = miniRunLauncher.launch(configFilePath, fingerprintFilter, observer, token);
|
||||
if (outcome == null) {
|
||||
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||
"Mini-Run-Launcher hat kein Ergebnis geliefert.");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
|
||||
e.getMessage(), e);
|
||||
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||
"Unerwarteter technischer Fehler im Mini-Lauf: "
|
||||
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||
}
|
||||
finishRun(outcome);
|
||||
}
|
||||
|
||||
private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
||||
LOG.info("GUI-Status-Reset: Worker-Thread gestartet für {} Dokument(e), "
|
||||
+ "Konfiguration {}.", fingerprints.size(), configFilePath);
|
||||
ResetDocumentStatusResult result;
|
||||
try {
|
||||
result = resetPort.reset(configFilePath, fingerprints);
|
||||
if (result == null) {
|
||||
result = new ResetDocumentStatusResult(fingerprints.size(),
|
||||
Set.of(), allFailureMap(fingerprints,
|
||||
"Reset-Port hat kein Ergebnis geliefert."));
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("GUI-Status-Reset: Unerwarteter Fehler im Worker-Thread: {}",
|
||||
e.getMessage(), e);
|
||||
String msg = "Unerwarteter technischer Fehler beim Status-Reset: "
|
||||
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||
result = new ResetDocumentStatusResult(fingerprints.size(),
|
||||
Set.of(), allFailureMap(fingerprints, msg));
|
||||
}
|
||||
ResetDocumentStatusResult finalResult = result;
|
||||
activeWorker.set(null);
|
||||
fxDispatcher.accept(() -> listener.onResetCompleted(finalResult));
|
||||
LOG.info("GUI-Status-Reset: Worker-Thread beendet.");
|
||||
}
|
||||
|
||||
private void finishRun(GuiBatchRunLaunchOutcome outcome) {
|
||||
RunSummary summary = observerSummary.get();
|
||||
if (summary == null) {
|
||||
summary = new RunSummary(0, 0, 0);
|
||||
}
|
||||
GuiBatchRunLaunchOutcome finalOutcome = outcome;
|
||||
RunSummary finalSummary = summary;
|
||||
activeWorker.set(null);
|
||||
fxDispatcher.accept(() -> listener.onRunEnded(finalSummary, finalOutcome));
|
||||
LOG.info("GUI-Verarbeitungslauf: Worker-Thread beendet.");
|
||||
}
|
||||
|
||||
private static java.util.Map<DocumentFingerprint, String> allFailureMap(
|
||||
Set<DocumentFingerprint> fingerprints, String message) {
|
||||
java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
|
||||
for (DocumentFingerprint fp : fingerprints) {
|
||||
map.put(fp, message);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the final summary supplied by the application layer. Written on the
|
||||
* worker thread; read only after the run has ended.
|
||||
*/
|
||||
private final AtomicReference<RunSummary> observerSummary = new AtomicReference<>();
|
||||
|
||||
private BatchRunProgressObserver buildDispatchingObserver(Path configFilePath) {
|
||||
return new BatchRunProgressObserver() {
|
||||
@Override
|
||||
public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
fxDispatcher.accept(() -> listener.onRunStarted(runId, totalCandidates));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDocumentCompleted(DocumentCompletionEvent event) {
|
||||
GuiBatchRunResultRow row = toRow(event, configFilePath);
|
||||
fxDispatcher.accept(() -> listener.onDocumentCompleted(row));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRunEnded(RunSummary summary) {
|
||||
observerSummary.set(summary);
|
||||
// Kein FX-Dispatch hier: der Worker-Thread ruft onRunEnded über finishRun()
|
||||
// auf, nachdem der Launcher zurückgekehrt ist.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt ein {@link DocumentCompletionEvent} in eine {@link GuiBatchRunResultRow} um.
|
||||
* <p>
|
||||
* Für übersprungene Dokumente ({@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}
|
||||
* und {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}) wird der historische
|
||||
* Verarbeitungskontext über den {@link GuiHistoricalDocumentContextPort} nachgeladen.
|
||||
* Für SKIPPED_ALREADY_PROCESSED wird der letzte Zieldateiname aus dem Kontext als
|
||||
* {@code finalName} übernommen. Schlägt die Abfrage fehl, bleibt der Kontext leer.
|
||||
* Die Methode läuft auf dem Worker-Thread.
|
||||
*
|
||||
* @param event das abgeschlossene Kandidatenereignis; darf nicht {@code null} sein
|
||||
* @param configFilePath Pfad zur aktiven Konfigurationsdatei; darf nicht {@code null} sein
|
||||
* @return eine neue {@link GuiBatchRunResultRow}; nie {@code null}
|
||||
*/
|
||||
private GuiBatchRunResultRow toRow(DocumentCompletionEvent event, Path configFilePath) {
|
||||
Optional<String> finalName = event.finalFileName() == null
|
||||
? Optional.empty() : Optional.of(event.finalFileName());
|
||||
Optional<LocalDate> date = event.resolvedDate() == null
|
||||
? Optional.empty() : Optional.of(event.resolvedDate());
|
||||
Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
|
||||
? Optional.empty() : Optional.of(event.aiReasoning());
|
||||
Optional<String> failureMessage = event.failureMessage() == null || event.failureMessage().isBlank()
|
||||
? Optional.empty() : Optional.of(event.failureMessage());
|
||||
Duration duration = event.processingDuration();
|
||||
|
||||
// Historischen Kontext für übersprungene Dokumente nachladen
|
||||
boolean isSkipped = event.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED
|
||||
|| event.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE;
|
||||
Optional<HistoricalDocumentContext> historicalContext = Optional.empty();
|
||||
if (isSkipped) {
|
||||
try {
|
||||
historicalContext = historicalDocumentContextPort
|
||||
.resolveHistoricalDocumentContext(configFilePath, event.fingerprint());
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Historischer Kontext konnte nicht abgefragt werden für {}: {}",
|
||||
event.originalFileName(), e.getMessage(), e);
|
||||
}
|
||||
// Zieldateiname für SKIPPED_ALREADY_PROCESSED aus Kontext übernehmen
|
||||
if (finalName.isEmpty()) {
|
||||
finalName = historicalContext
|
||||
.flatMap(HistoricalDocumentContext::lastTargetFileName);
|
||||
}
|
||||
}
|
||||
|
||||
return new GuiBatchRunResultRow(
|
||||
event.originalFileName(),
|
||||
event.fingerprint(),
|
||||
event.status(),
|
||||
finalName,
|
||||
Optional.empty(),
|
||||
date,
|
||||
reasoning,
|
||||
failureMessage,
|
||||
duration,
|
||||
false,
|
||||
historicalContext);
|
||||
}
|
||||
|
||||
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
|
||||
return (configPath, fingerprint) -> Optional.empty();
|
||||
}
|
||||
|
||||
private static Function<Runnable, Thread> defaultThreadFactory() {
|
||||
return task -> {
|
||||
Thread thread = new Thread(task, WORKER_THREAD_NAME);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
|
||||
private static Consumer<Runnable> defaultFxDispatcher() {
|
||||
return Platform::runLater;
|
||||
}
|
||||
|
||||
private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
|
||||
return (configFilePath, fingerprintFilter, observer, cancellationToken) ->
|
||||
GuiBatchRunLaunchOutcome.rejected(
|
||||
"Kein Mini-Run-Launcher in diesem Kontext verfügbar.");
|
||||
}
|
||||
|
||||
private static GuiResetDocumentStatusPort rejectingResetPort() {
|
||||
return (configFilePath, fingerprints) ->
|
||||
new ResetDocumentStatusResult(fingerprints.size(),
|
||||
Set.of(), allFailureMapStatic(fingerprints,
|
||||
"Kein Reset-Port in diesem Kontext verfügbar."));
|
||||
}
|
||||
|
||||
private static java.util.Map<DocumentFingerprint, String> allFailureMapStatic(
|
||||
Set<DocumentFingerprint> fingerprints, String message) {
|
||||
java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
|
||||
for (DocumentFingerprint fp : fingerprints) {
|
||||
map.put(fp, message);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Immutable result of a single batch run launched from the GUI.
|
||||
* <p>
|
||||
* The outcome reports to the tab whether the run finished normally, could not even be
|
||||
* started (hard failure), or ended because of an unexpected exception. The GUI uses this
|
||||
* to transition between its "laufend" and "bereit"/"Fehler" states.
|
||||
*
|
||||
* <h2>Fields</h2>
|
||||
* <ul>
|
||||
* <li>{@link #successfullyStarted()} — {@code true} when the launcher managed to enter
|
||||
* the batch execution phase; {@code false} when the run was rejected before any
|
||||
* candidate could be processed (e.g. configuration invalid, lock held, SQLite
|
||||
* unavailable).</li>
|
||||
* <li>{@link #batchCompletedNormally()} — {@code true} when the run returned from the
|
||||
* batch use case with a normal outcome (whether empty, partial, or full). Only
|
||||
* meaningful when {@link #successfullyStarted()} is also {@code true}.</li>
|
||||
* <li>{@link #failureMessage()} — present when either the run could not start or an
|
||||
* unexpected technical exception terminated it. Empty when the run completed
|
||||
* normally.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public record GuiBatchRunLaunchOutcome(
|
||||
boolean successfullyStarted,
|
||||
boolean batchCompletedNormally,
|
||||
Optional<String> failureMessage) {
|
||||
|
||||
/**
|
||||
* Compact constructor normalising the failure message holder.
|
||||
*/
|
||||
public GuiBatchRunLaunchOutcome {
|
||||
failureMessage = failureMessage == null ? Optional.empty() : failureMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an outcome describing a run that finished normally.
|
||||
*
|
||||
* @return a started + completed outcome without failure message
|
||||
*/
|
||||
public static GuiBatchRunLaunchOutcome completed() {
|
||||
return new GuiBatchRunLaunchOutcome(true, true, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an outcome describing a run that could not start because of a hard
|
||||
* configuration, persistence, or lock failure.
|
||||
*
|
||||
* @param failureMessage the user-visible German failure description; must not be blank
|
||||
* @return a rejected-startup outcome carrying the supplied message
|
||||
*/
|
||||
public static GuiBatchRunLaunchOutcome rejected(String failureMessage) {
|
||||
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
|
||||
if (failureMessage.isBlank()) {
|
||||
throw new IllegalArgumentException("failureMessage must not be blank");
|
||||
}
|
||||
return new GuiBatchRunLaunchOutcome(false, false, Optional.of(failureMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an outcome describing a run that started but ended due to an unexpected
|
||||
* technical exception.
|
||||
*
|
||||
* @param failureMessage the user-visible German failure description; must not be blank
|
||||
* @return an aborted-after-start outcome carrying the supplied message
|
||||
*/
|
||||
public static GuiBatchRunLaunchOutcome failedAfterStart(String failureMessage) {
|
||||
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
|
||||
if (failureMessage.isBlank()) {
|
||||
throw new IllegalArgumentException("failureMessage must not be blank");
|
||||
}
|
||||
return new GuiBatchRunLaunchOutcome(true, false, Optional.of(failureMessage));
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||
|
||||
/**
|
||||
* Inbound bridge implemented by Bootstrap to let the GUI execute a batch run against a
|
||||
* stored configuration file.
|
||||
* <p>
|
||||
* The launcher performs the complete headless startup sequence (legacy migration, config
|
||||
* loading, validation, SQLite schema initialisation, run-lock, use-case wiring, execution)
|
||||
* for the supplied configuration path while forwarding progress callbacks and honouring
|
||||
* the supplied cancellation token. It reuses the very same application ports and
|
||||
* persistence pipeline as a Task-Scheduler-triggered headless run; only the presentation
|
||||
* side (the GUI) differs.
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* <p>
|
||||
* Implementations must be safe to call from a non-UI worker thread. They must not touch
|
||||
* the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
|
||||
* caller's concern. The call blocks until the run terminates (normally, after a
|
||||
* cancellation, or after a hard failure).
|
||||
*
|
||||
* <h2>Exception contract</h2>
|
||||
* <p>
|
||||
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
|
||||
* should be caught, logged, and returned as a
|
||||
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
|
||||
* well-defined terminal state.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiBatchRunLauncher {
|
||||
|
||||
/**
|
||||
* Executes exactly one batch run against the supplied configuration file.
|
||||
*
|
||||
* @param configFilePath path of the {@code .properties} file to run against;
|
||||
* must not be {@code null}; must exist and be readable
|
||||
* @param observer observer receiving start/completion/end callbacks; must
|
||||
* not be {@code null}
|
||||
* @param cancellationToken cancellation token the run polls between candidates; must
|
||||
* not be {@code null}
|
||||
* @return a description of how the run terminated; never {@code null}
|
||||
*/
|
||||
GuiBatchRunLaunchOutcome launch(
|
||||
Path configFilePath,
|
||||
BatchRunProgressObserver observer,
|
||||
BatchRunCancellationToken cancellationToken);
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Immutable view model for a single row in the processing-run result list.
|
||||
* <p>
|
||||
* Each completed candidate becomes exactly one row. The row carries only the information
|
||||
* that is shown in the list and the side panel; it is decoupled from the persistence
|
||||
* model so later GUI layers can render it without reaching back into the application
|
||||
* layer.
|
||||
* <p>
|
||||
* The {@code fingerprint} field is the content-based identity of the document and is
|
||||
* used as a stable key for in-place row updates during a targeted mini-run.
|
||||
* <p>
|
||||
* When {@code resetPending} is {@code true} the row represents a document whose
|
||||
* persistence status has been deleted but which has not yet been reprocessed. The status
|
||||
* icon and label reflect this special state instead of the original processing outcome.
|
||||
*
|
||||
* @param originalFileName the source filename as reported by the use case; never
|
||||
* {@code null} or blank
|
||||
* @param fingerprint the content-based identity of the processed document; never
|
||||
* {@code null}
|
||||
* @param status the aggregated completion status; never {@code null}
|
||||
* @param finalFileName the final target filename when the row represents a successful
|
||||
* rename; empty otherwise
|
||||
* @param correctedFileName Der manuell korrigierte Zieldateiname, falls der Benutzer den
|
||||
* KI-Vorschlag in der GUI bearbeitet und gespeichert hat.
|
||||
* Leer bei unverändertem KI-Vorschlag.
|
||||
* @param resolvedDate the resolved document date when the row represents a successful
|
||||
* rename; empty otherwise
|
||||
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
|
||||
* reasoning is available for this row
|
||||
* @param aiFailureMessage eine lesbare Fehlerbeschreibung, wenn der KI-Aufruf oder die
|
||||
* Verarbeitung fehlgeschlagen ist; leer bei Erfolg und
|
||||
* übersprungenen Dokumenten
|
||||
* @param processingDuration wall-clock duration spent on the candidate in this run;
|
||||
* never {@code null} and never negative
|
||||
* @param resetPending {@code true} when the document's persistence status has been
|
||||
* reset and is awaiting the next processing run
|
||||
* @param historicalContext historischer Verarbeitungskontext für übersprungene Dokumente;
|
||||
* leer bei nicht-übersprungenen Zeilen
|
||||
*/
|
||||
public record GuiBatchRunResultRow(
|
||||
String originalFileName,
|
||||
DocumentFingerprint fingerprint,
|
||||
DocumentCompletionStatus status,
|
||||
Optional<String> finalFileName,
|
||||
Optional<String> correctedFileName,
|
||||
Optional<LocalDate> resolvedDate,
|
||||
Optional<String> aiReasoning,
|
||||
Optional<String> aiFailureMessage,
|
||||
Duration processingDuration,
|
||||
boolean resetPending,
|
||||
Optional<HistoricalDocumentContext> historicalContext) {
|
||||
|
||||
/**
|
||||
* Label shown in the status column when a document's persistence status has been
|
||||
* reset and is waiting for the next processing run.
|
||||
*/
|
||||
static final String RESET_PENDING_LABEL = "Zurückgesetzt – wartet auf nächsten Lauf";
|
||||
|
||||
/**
|
||||
* Icon shown in the status column when a document's persistence status has been reset.
|
||||
*/
|
||||
static final String RESET_PENDING_ICON = "⟳"; // ⟳ CLOCKWISE GAPPED CIRCLE ARROW
|
||||
|
||||
/**
|
||||
* Compact constructor normalising optional holders and validating mandatory fields.
|
||||
*
|
||||
* @throws NullPointerException if {@code originalFileName}, {@code fingerprint},
|
||||
* {@code status} or {@code processingDuration} is
|
||||
* {@code null}
|
||||
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
||||
* {@code processingDuration} is negative
|
||||
*/
|
||||
public GuiBatchRunResultRow {
|
||||
Objects.requireNonNull(originalFileName, "originalFileName must not be null");
|
||||
if (originalFileName.isBlank()) {
|
||||
throw new IllegalArgumentException("originalFileName must not be blank");
|
||||
}
|
||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||
Objects.requireNonNull(status, "status must not be null");
|
||||
finalFileName = 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;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bequem-Konstruktor für Zeilen, die weder einen manuell korrigierten Dateinamen
|
||||
* tragen noch im reset-pending-Zustand stehen und keinen historischen Kontext haben.
|
||||
*
|
||||
* @param originalFileName the source filename; never {@code null} or blank
|
||||
* @param fingerprint the content-based document identity; never {@code null}
|
||||
* @param status the aggregated completion status; never {@code null}
|
||||
* @param finalFileName the final target filename; may be {@code null} (treated as
|
||||
* empty)
|
||||
* @param resolvedDate the resolved document date; may be {@code null} (treated as
|
||||
* empty)
|
||||
* @param aiReasoning the AI reasoning text; may be {@code null} (treated as
|
||||
* empty)
|
||||
* @param aiFailureMessage eine lesbare Fehlerbeschreibung bei Fehler; may be
|
||||
* {@code null} (treated as empty)
|
||||
* @param processingDuration the wall-clock processing duration; never {@code null}
|
||||
*/
|
||||
public GuiBatchRunResultRow(
|
||||
String originalFileName,
|
||||
DocumentFingerprint fingerprint,
|
||||
DocumentCompletionStatus status,
|
||||
Optional<String> finalFileName,
|
||||
Optional<LocalDate> resolvedDate,
|
||||
Optional<String> aiReasoning,
|
||||
Optional<String> aiFailureMessage,
|
||||
Duration processingDuration) {
|
||||
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
|
||||
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, false,
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bequem-Konstruktor mit explizitem {@code resetPending}-Flag, aber ohne manuell
|
||||
* korrigierten Dateinamen und ohne historischen Kontext.
|
||||
*
|
||||
* @param originalFileName the source filename; never {@code null} or blank
|
||||
* @param fingerprint the content-based document identity; never {@code null}
|
||||
* @param status the aggregated completion status; never {@code null}
|
||||
* @param finalFileName the final target filename; may be {@code null} (treated as
|
||||
* empty)
|
||||
* @param resolvedDate the resolved document date; may be {@code null} (treated as
|
||||
* empty)
|
||||
* @param aiReasoning the AI reasoning text; may be {@code null} (treated as
|
||||
* empty)
|
||||
* @param aiFailureMessage eine lesbare Fehlerbeschreibung bei Fehler; may be
|
||||
* {@code null} (treated as empty)
|
||||
* @param processingDuration the wall-clock processing duration; never {@code null}
|
||||
* @param resetPending {@code true} wenn der Stammsatz zurückgesetzt wurde
|
||||
*/
|
||||
public GuiBatchRunResultRow(
|
||||
String originalFileName,
|
||||
DocumentFingerprint fingerprint,
|
||||
DocumentCompletionStatus status,
|
||||
Optional<String> finalFileName,
|
||||
Optional<LocalDate> resolvedDate,
|
||||
Optional<String> aiReasoning,
|
||||
Optional<String> aiFailureMessage,
|
||||
Duration processingDuration,
|
||||
boolean resetPending) {
|
||||
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
|
||||
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, resetPending,
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reset-pending copy of the supplied row, preserving the original filename
|
||||
* and fingerprint while marking the row as awaiting the next processing run.
|
||||
* <p>
|
||||
* The returned row has {@code resetPending == true}. Its {@code statusIcon()} and
|
||||
* {@code statusLabel()} reflect the reset state.
|
||||
*
|
||||
* @param previousRow the row to copy; must not be {@code null}
|
||||
* @return a new row with the same filename and fingerprint, {@code resetPending == true}
|
||||
* @throws NullPointerException if {@code previousRow} is {@code null}
|
||||
*/
|
||||
public static GuiBatchRunResultRow resetMarker(GuiBatchRunResultRow previousRow) {
|
||||
Objects.requireNonNull(previousRow, "previousRow must not be null");
|
||||
return new GuiBatchRunResultRow(
|
||||
previousRow.originalFileName(),
|
||||
previousRow.fingerprint(),
|
||||
previousRow.status(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Duration.ZERO,
|
||||
true,
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt das Status-Icon für diese Zeile als Unicode-Zeichen zurück, das in JavaFX
|
||||
* unter Windows zuverlässig dargestellt wird (16px, bold).
|
||||
* <p>
|
||||
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
||||
* eigentlichen Status das Reset-Icon zurückgegeben.
|
||||
* <p>
|
||||
* Die Icon-Werte stammen aus {@link ProcessingStatusPresentation}.
|
||||
*
|
||||
* @return das entsprechende Status-Zeichen
|
||||
*/
|
||||
public String statusIcon() {
|
||||
if (resetPending) {
|
||||
return RESET_PENDING_ICON;
|
||||
}
|
||||
return ProcessingStatusPresentation.iconFor(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die CSS-Farbe für das Status-Icon dieser Zeile zurück.
|
||||
* <p>
|
||||
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
||||
* eigentlichen Status die Reset-Farbe zurückgegeben.
|
||||
* <p>
|
||||
* Farbe ist niemals das einzige Unterscheidungsmerkmal – {@link #statusIcon()} und
|
||||
* {@link #statusTooltip()} beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
|
||||
* Die Farbwerte stammen aus {@link ProcessingStatusPresentation}.
|
||||
*
|
||||
* @return die entsprechende CSS-Hex-Farbe (z. B. {@code "#2e7d32"})
|
||||
*/
|
||||
public String statusColor() {
|
||||
if (resetPending) {
|
||||
return "#757575"; // Grau für Reset-pending
|
||||
}
|
||||
return ProcessingStatusPresentation.cssColorFor(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den deutschsprachigen Tooltip-Text für den Verarbeitungsstatus dieser Zeile zurück.
|
||||
* <p>
|
||||
* Wenn {@code resetPending} den Wert {@code true} hat, wird ein Tooltip für den
|
||||
* Reset-Zustand zurückgegeben.
|
||||
* <p>
|
||||
* Der Tooltip-Text beschreibt den Status vollständig ohne Farbe. Die Texte stammen
|
||||
* aus {@link ProcessingStatusPresentation}.
|
||||
*
|
||||
* @return der Tooltip-Text; nie leer
|
||||
*/
|
||||
public String statusTooltip() {
|
||||
if (resetPending) {
|
||||
return RESET_PENDING_LABEL;
|
||||
}
|
||||
return ProcessingStatusPresentation.tooltipFor(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable status label for this row.
|
||||
* <p>
|
||||
* When {@code resetPending} is {@code true} the reset-pending label is returned
|
||||
* regardless of the underlying status.
|
||||
*
|
||||
* @return a non-null German status label
|
||||
*/
|
||||
public String statusLabel() {
|
||||
if (resetPending) {
|
||||
return RESET_PENDING_LABEL;
|
||||
}
|
||||
return switch (status) {
|
||||
case SUCCESS -> "Erfolgreich";
|
||||
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
|
||||
case FAILED_PERMANENT -> "Fehlgeschlagen (dauerhaft)";
|
||||
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen (bereits verarbeitet)";
|
||||
case SKIPPED_FINAL_FAILURE -> "Übersprungen (endgültig fehlgeschlagen)";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den aktuell wirksamen Zieldateinamen: falls der Benutzer den KI-Vorschlag
|
||||
* manuell korrigiert und gespeichert hat, wird der korrigierte Name geliefert,
|
||||
* ansonsten der ursprüngliche KI-Vorschlag {@link #finalFileName()}.
|
||||
* <p>
|
||||
* Die Tabellenspalte „Neuer Dateiname" bindet an diesen Wert.
|
||||
*
|
||||
* @return den aktuell anzuzeigenden Zieldateinamen; leer wenn kein Name vorliegt
|
||||
*/
|
||||
public Optional<String> effectiveFileName() {
|
||||
if (correctedFileName.isPresent()) {
|
||||
return correctedFileName;
|
||||
}
|
||||
return finalFileName;
|
||||
}
|
||||
}
|
||||
+1733
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* GUI-interner Port zum Abfragen des historischen Verarbeitungskontexts einer Quelldatei.
|
||||
* <p>
|
||||
* Wird im Verarbeitungslauf-Tab genutzt, um für übersprungene Dokumente
|
||||
* ({@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}
|
||||
* und
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED_FINAL_FAILURE})
|
||||
* den historischen Kontext nachzuschlagen. Der Kontext wird im Detailbereich des
|
||||
* Verarbeitungslauf-Tabs angezeigt.
|
||||
* <p>
|
||||
* Die Bootstrap-Schicht liefert die konkrete Implementierung. Sie lädt die
|
||||
* Konfiguration aus {@code configFilePath}, baut den zugehörigen Use-Case auf und
|
||||
* gibt das Ergebnis zurück. Technische Fehler beim Laden oder Abfragen werden intern
|
||||
* abgefangen und als leeres {@link Optional} zurückgegeben.
|
||||
* <p>
|
||||
* Die Implementierung läuft auf dem Worker-Thread des {@link GuiBatchRunCoordinator}
|
||||
* und darf blockieren.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiHistoricalDocumentContextPort {
|
||||
|
||||
/**
|
||||
* Gibt den historischen Verarbeitungskontext für das durch {@code fingerprint}
|
||||
* identifizierte Dokument zurück, oder ein leeres {@link Optional}, wenn kein
|
||||
* Kontext verfügbar ist.
|
||||
*
|
||||
* @param configFilePath Pfad zur aktiven {@code .properties}-Konfigurationsdatei;
|
||||
* darf nicht {@code null} sein
|
||||
* @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
|
||||
* @return historischer Kontext des Dokuments, oder leer wenn nicht verfügbar
|
||||
*/
|
||||
Optional<HistoricalDocumentContext> resolveHistoricalDocumentContext(
|
||||
Path configFilePath, DocumentFingerprint fingerprint);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* GUI-interner Port zum Abfragen des historischen KI-Dateinamens einer Quelldatei.
|
||||
* <p>
|
||||
* Wird im Verarbeitungslauf-Tab genutzt, um für übersprungene Dokumente
|
||||
* ({@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED})
|
||||
* den aus einem früheren Lauf bekannten Zieldateinamen nachzuschlagen und in der Spalte
|
||||
* „Neuer Dateiname" der Ergebnistabelle anzuzeigen.
|
||||
* <p>
|
||||
* Die Bootstrap-Schicht liefert die konkrete Implementierung. Sie lädt die Konfiguration
|
||||
* aus {@code configFilePath}, baut den zugehörigen Use-Case auf und gibt das Ergebnis
|
||||
* zurück. Technische Fehler beim Laden oder Abfragen dürfen nicht als Exception propagiert
|
||||
* werden; sie werden intern behandelt und als leeres {@link Optional} zurückgegeben.
|
||||
* <p>
|
||||
* Die Implementierung läuft auf dem Worker-Thread des {@link GuiBatchRunCoordinator}
|
||||
* und darf blockieren.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiHistoricalFileNamePort {
|
||||
|
||||
/**
|
||||
* Gibt den letzten erfolgreich geschriebenen Zieldateinamen für das durch
|
||||
* {@code fingerprint} identifizierte Dokument zurück, oder ein leeres
|
||||
* {@link Optional}, wenn kein solcher Name verfügbar ist.
|
||||
*
|
||||
* @param configFilePath Pfad zur aktiven {@code .properties}-Konfigurationsdatei;
|
||||
* darf nicht {@code null} sein
|
||||
* @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
|
||||
* @return der historische Zieldateiname, oder leer wenn nicht vorhanden
|
||||
*/
|
||||
Optional<String> resolveHistoricalFileName(Path configFilePath, DocumentFingerprint fingerprint);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
|
||||
|
||||
/**
|
||||
* Inbound-Brücke für die manuelle Kopie der Quelldatei eines bislang nicht erfolgreich
|
||||
* verarbeiteten Dokuments aus der GUI.
|
||||
* <p>
|
||||
* Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen, wenn der
|
||||
* Benutzer für ein nicht erfolgreich verarbeitetes Dokument (Status {@code FAILED_*} oder
|
||||
* {@code SKIPPED_FINAL_FAILURE}) einen manuellen Zieldateinamen bestätigt. Der Port
|
||||
* kapselt das vollständige Wiring (Konfigurationsauflösung, Use-Case-Konstruktion und
|
||||
* Ausführung), sodass der GUI-Adapter keine Kenntnis von infrastrukturellen
|
||||
* Implementierungsdetails benötigt.
|
||||
*
|
||||
* <h2>Threadingmodell</h2>
|
||||
* <p>
|
||||
* Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung ist
|
||||
* synchron und blockierend: Sie kehrt erst zurück, wenn die Kopie abgeschlossen oder
|
||||
* fehlgeschlagen ist. Aufrufer aus dem GUI-Layer müssen den Aufruf daher auf einem
|
||||
* Hintergrund-Worker-Thread ausführen und das Ergebnis anschließend per
|
||||
* {@code Platform.runLater} auf den JavaFX-Application-Thread zurückführen.
|
||||
*
|
||||
* <h2>Exception-Vertrag</h2>
|
||||
* <p>
|
||||
* Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete
|
||||
* Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileCopyResult}
|
||||
* zurückgegeben werden.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiManualFileCopyPort {
|
||||
|
||||
/**
|
||||
* Kopiert die Quelldatei eines Dokuments mit benutzerdefiniertem Zieldateinamen ins
|
||||
* Zielverzeichnis und aktualisiert den Dokument-Stammsatz auf {@code SUCCESS}.
|
||||
*
|
||||
* @param configFilePath Pfad zur {@code .properties}-Datei, die SQLite-Datenbank,
|
||||
* Quell- und Zielordner beschreibt; darf nicht {@code null} sein;
|
||||
* muss existieren und lesbar sein
|
||||
* @param request die Kopieranfrage mit Fingerprint und gewünschtem
|
||||
* Basisdateinamen; darf nicht {@code null} sein
|
||||
* @return das Ergebnis der Kopieroperation; nie {@code null}
|
||||
*/
|
||||
ManualFileCopyResult copy(Path configFilePath, ManualFileCopyRequest request);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
|
||||
|
||||
/**
|
||||
* Inbound-Brücke für die manuelle Dateiumbenennung aus der GUI.
|
||||
* <p>
|
||||
* Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen,
|
||||
* wenn der Benutzer einen geänderten Dateinamen bestätigt. Der Port kapselt
|
||||
* das vollständige Wiring (Konfigurationsauflösung, Use-Case-Konstruktion und
|
||||
* Ausführung), sodass der GUI-Adapter keine Kenntnis von infrastrukturellen
|
||||
* Implementierungsdetails benötigt.
|
||||
*
|
||||
* <h2>Threadingmodell</h2>
|
||||
* <p>
|
||||
* Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung
|
||||
* ist synchron und blockierend: Sie kehrt erst zurück, wenn die Umbenennung
|
||||
* abgeschlossen oder fehlgeschlagen ist. Aufrufer aus dem GUI-Layer müssen den
|
||||
* Aufruf daher auf einem Hintergrund-Worker-Thread ausführen und das Ergebnis
|
||||
* anschließend per {@code Platform.runLater} auf den JavaFX-Application-Thread
|
||||
* zurückführen.
|
||||
*
|
||||
* <h2>Exception-Vertrag</h2>
|
||||
* <p>
|
||||
* Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete
|
||||
* Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileRenameResult}
|
||||
* zurückgegeben werden.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiManualFileRenamePort {
|
||||
|
||||
/**
|
||||
* Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
|
||||
*
|
||||
* @param configFilePath Pfad zur {@code .properties}-Datei, die die SQLite-Datenbank
|
||||
* und den Zielordner beschreibt; darf nicht {@code null} sein;
|
||||
* muss existieren und lesbar sein
|
||||
* @param request die Umbenennungsanfrage mit Fingerprint und gewünschtem
|
||||
* Basisdateinamen; darf nicht {@code null} sein
|
||||
* @return das Ergebnis der Umbenennung; nie {@code null}
|
||||
*/
|
||||
ManualFileRenameResult rename(Path configFilePath, ManualFileRenameRequest request);
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Inbound bridge implemented by Bootstrap to let the GUI execute a targeted mini batch
|
||||
* run restricted to a specific set of document fingerprints.
|
||||
* <p>
|
||||
* A mini-run applies the full processing pipeline — legacy migration, configuration
|
||||
* loading, validation, SQLite schema initialisation, run-lock, use-case wiring, and
|
||||
* execution — but limits processing to the supplied fingerprint set. Documents not in
|
||||
* the set are silently skipped without any persistence side-effects.
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* <p>
|
||||
* Implementations must be safe to call from a non-UI worker thread. They must not touch
|
||||
* the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
|
||||
* caller's concern. The call blocks until the run terminates (normally, after a
|
||||
* cancellation, or after a hard failure).
|
||||
*
|
||||
* <h2>Exception contract</h2>
|
||||
* <p>
|
||||
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
|
||||
* should be caught, logged, and returned as a
|
||||
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
|
||||
* well-defined terminal state.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiMiniRunLauncher {
|
||||
|
||||
/**
|
||||
* Executes a targeted batch run restricted to the supplied fingerprint set.
|
||||
*
|
||||
* @param configFilePath path of the {@code .properties} file to run against;
|
||||
* must not be {@code null}; must exist and be readable
|
||||
* @param fingerprintFilter the set of document fingerprints to process; must not be
|
||||
* {@code null}; an empty set results in a completed run
|
||||
* that processes nothing
|
||||
* @param observer observer receiving start/completion/end callbacks; must
|
||||
* not be {@code null}
|
||||
* @param cancellationToken cancellation token the run polls between candidates; must
|
||||
* not be {@code null}
|
||||
* @return a description of how the run terminated; never {@code null}
|
||||
*/
|
||||
GuiBatchRunLaunchOutcome launch(
|
||||
Path configFilePath,
|
||||
Set<DocumentFingerprint> fingerprintFilter,
|
||||
BatchRunProgressObserver observer,
|
||||
BatchRunCancellationToken cancellationToken);
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Inbound bridge implemented by Bootstrap to let the GUI reset the processing status
|
||||
* of one or more documents without triggering an immediate reprocessing run.
|
||||
* <p>
|
||||
* A reset deletes all persistence data (attempt history and document master record)
|
||||
* for the specified fingerprints, making them eligible for reprocessing in the next
|
||||
* regular or targeted batch run as if they had never been processed.
|
||||
* <p>
|
||||
* The operation follows best-effort semantics: each fingerprint is attempted
|
||||
* independently. Technical failures for individual fingerprints are recorded in the
|
||||
* result and do not abort the remaining resets.
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* <p>
|
||||
* Implementations must be safe to call from a non-UI worker thread. The call blocks
|
||||
* until all reset operations have completed or failed.
|
||||
*
|
||||
* <h2>Exception contract</h2>
|
||||
* <p>
|
||||
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
|
||||
* should be caught and represented as failures in the result map.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GuiResetDocumentStatusPort {
|
||||
|
||||
/**
|
||||
* Resets the processing status for the supplied set of document fingerprints.
|
||||
*
|
||||
* @param configFilePath path of the {@code .properties} file that identifies the
|
||||
* SQLite database to operate on; must not be {@code null};
|
||||
* must exist and be readable
|
||||
* @param fingerprints the set of document fingerprints to reset; must not be
|
||||
* {@code null}; may be empty
|
||||
* @return a {@link ResetDocumentStatusResult} describing the full outcome; never null
|
||||
*/
|
||||
ResetDocumentStatusResult reset(Path configFilePath, Set<DocumentFingerprint> fingerprints);
|
||||
}
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.embed.swing.SwingFXUtils;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
|
||||
*
|
||||
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
|
||||
* in einer {@link ImageView} an. Die Anzeige ist vollständig eingepasst (fit-to-view):
|
||||
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} sind an die Größe des
|
||||
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
|
||||
* Seitenverhältnis. Es entstehen weder Scrollbalken noch Zoom-Artefakte.
|
||||
*
|
||||
* <p>Das Laden der PDF-Datei und das Rendering einzelner Seiten erfolgt auf einem
|
||||
* dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX
|
||||
* Application Thread. Bereits gerenderte Seiten werden in einem In-Memory-Cache
|
||||
* ({@code Map<Integer, Image>}) gehalten, sodass wiederholte Navigation kein
|
||||
* erneutes Rendering erfordert. Der Cache wird beim Wechsel der Quelldatei geleert.
|
||||
*
|
||||
* <p>Es gilt das Prinzip „Latest Preview Request Wins": Veraltete Lade- und
|
||||
* Rendering-Ergebnisse werden anhand einer Sequenznummer erkannt und verworfen,
|
||||
* sobald eine neue Anforderung eingeht.
|
||||
*
|
||||
* <h2>Fehlerfälle</h2>
|
||||
* <ul>
|
||||
* <li>Quelldatei nicht vorhanden → Meldungstext im Vorschaubereich</li>
|
||||
* <li>PDF nicht lesbar → Meldungstext im Vorschaubereich</li>
|
||||
* <li>PDF passwortgeschützt → Meldungstext im Vorschaubereich</li>
|
||||
* <li>Keine Selektion → neutraler Platzhaltertext</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
|
||||
* werden. Das PDF-Öffnen, die Speicherhaltung des {@link PDDocument} und das
|
||||
* Rendering einzelner Seiten laufen ausschließlich auf dem Worker-Thread.
|
||||
*/
|
||||
public final class PdfPreviewPane {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(PdfPreviewPane.class);
|
||||
|
||||
static final String PLACEHOLDER_TEXT = "Keine Datei ausgewählt";
|
||||
static final String FILE_NOT_FOUND_TEXT = "Quelldatei nicht gefunden";
|
||||
static final String PDF_UNREADABLE_TEXT = "PDF konnte nicht geöffnet werden";
|
||||
static final String PDF_PASSWORD_PROTECTED_TEXT =
|
||||
"PDF ist passwortgeschützt und kann nicht angezeigt werden";
|
||||
|
||||
/** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
|
||||
private static final float RENDER_DPI = 120f;
|
||||
|
||||
private final VBox root = new VBox(4);
|
||||
private final StackPane viewStack = new StackPane();
|
||||
private final ImageView imageView = new ImageView();
|
||||
private final Label overlayLabel = new Label(PLACEHOLDER_TEXT);
|
||||
private final ProgressIndicator progressIndicator = new ProgressIndicator();
|
||||
private final Label pageLabel = new Label();
|
||||
private final Button prevButton = new Button("◀ Vorherige");
|
||||
private final Button nextButton = new Button("Nächste ▶");
|
||||
private final Label sectionTitle = new Label("PDF-Vorschau");
|
||||
|
||||
/**
|
||||
* Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
|
||||
* (Laden oder Seitenwechsel) erhöht diesen Zähler. Lade-/Rendering-Ergebnisse
|
||||
* mit veralteter Sequenznummer werden verworfen.
|
||||
*/
|
||||
private final AtomicLong currentRequestSequence = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* Cache bereits gerenderter Seiten für die aktuell geladene Quelldatei.
|
||||
* Schlüssel ist die 1-basierte Seitennummer. Wird beim Wechsel der Quelldatei geleert.
|
||||
*/
|
||||
private final Map<Integer, Image> pageCache = new ConcurrentHashMap<>();
|
||||
|
||||
/** Hintergrund-Thread-Pool für Lade- und Rendering-Aufgaben. */
|
||||
private final ExecutorService executor =
|
||||
Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "pdf-preview-worker");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/**
|
||||
* Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread.
|
||||
* {@code null} wenn kein Dokument geöffnet ist.
|
||||
*/
|
||||
private volatile PDDocument currentDocument = null;
|
||||
|
||||
/**
|
||||
* Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread.
|
||||
* {@code null} wenn kein Dokument geöffnet ist.
|
||||
*/
|
||||
private volatile PDFRenderer currentRenderer = null;
|
||||
|
||||
/** Aktuell geladene Quelldatei; null wenn keine Selektion vorliegt. */
|
||||
private volatile Path currentSourceFile = null;
|
||||
|
||||
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
|
||||
private volatile int currentPage = 0;
|
||||
|
||||
/** Anzahl der Seiten der aktuell geladenen PDF; -1 wenn nicht ermittelt. */
|
||||
private volatile int totalPages = -1;
|
||||
|
||||
/** Gibt an ob die Navigation bedienbar ist. */
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
|
||||
*/
|
||||
public PdfPreviewPane() {
|
||||
sectionTitle.setStyle("-fx-font-weight: bold;");
|
||||
|
||||
imageView.setId("pdf-preview-image-view");
|
||||
imageView.setPreserveRatio(true);
|
||||
imageView.setSmooth(true);
|
||||
// Fit-to-view: ImageView füllt den verfügbaren Bereich unter Wahrung des Seitenverhältnisses
|
||||
imageView.fitWidthProperty().bind(viewStack.widthProperty());
|
||||
imageView.fitHeightProperty().bind(viewStack.heightProperty());
|
||||
|
||||
overlayLabel.setId("pdf-preview-overlay-label");
|
||||
overlayLabel.setStyle("-fx-text-fill: #555555;");
|
||||
overlayLabel.setWrapText(true);
|
||||
overlayLabel.setVisible(true);
|
||||
overlayLabel.setManaged(true);
|
||||
|
||||
progressIndicator.setId("pdf-preview-progress");
|
||||
progressIndicator.setVisible(false);
|
||||
progressIndicator.setManaged(false);
|
||||
progressIndicator.setMaxWidth(60);
|
||||
progressIndicator.setMaxHeight(60);
|
||||
|
||||
// Stack: ImageView hinter dem Overlay; Overlay überlagert das Bild bei Fehlern/Laden
|
||||
viewStack.getChildren().addAll(imageView, overlayLabel, progressIndicator);
|
||||
StackPane.setAlignment(imageView, Pos.CENTER);
|
||||
StackPane.setAlignment(overlayLabel, Pos.CENTER);
|
||||
StackPane.setAlignment(progressIndicator, Pos.CENTER);
|
||||
VBox.setVgrow(viewStack, Priority.ALWAYS);
|
||||
|
||||
prevButton.setId("pdf-preview-prev-button");
|
||||
prevButton.setOnAction(e -> navigateToPreviousPage());
|
||||
|
||||
nextButton.setId("pdf-preview-next-button");
|
||||
nextButton.setOnAction(e -> navigateToNextPage());
|
||||
|
||||
pageLabel.setId("pdf-preview-page-label");
|
||||
pageLabel.setStyle("-fx-text-fill: #555555;");
|
||||
|
||||
HBox navBar = new HBox(8, prevButton, pageLabel, nextButton);
|
||||
navBar.setAlignment(Pos.CENTER);
|
||||
navBar.setPadding(new Insets(4, 0, 4, 0));
|
||||
|
||||
root.getChildren().addAll(sectionTitle, viewStack, navBar);
|
||||
root.setPadding(new Insets(4, 0, 0, 0));
|
||||
|
||||
showPlaceholder();
|
||||
updateNavigationButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
|
||||
*
|
||||
* @return das Root-Control; nie null
|
||||
*/
|
||||
public Region getNode() {
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die angegebene Quelldatei asynchron und zeigt Seite 1 an.
|
||||
* Startet eine neue Vorschau-Anforderung und verwirft etwaige laufende Anforderungen.
|
||||
* Der Seiten-Cache wird geleert und ein etwaiges bereits geöffnetes PDF-Dokument
|
||||
* wird geschlossen.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*
|
||||
* @param sourceFile Pfad zur Quelldatei; null führt zu {@link #clear()}
|
||||
*/
|
||||
public void loadSource(Path sourceFile) {
|
||||
if (sourceFile == null) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
currentSourceFile = sourceFile;
|
||||
currentPage = 0;
|
||||
totalPages = -1;
|
||||
pageCache.clear();
|
||||
requestLoad(sourceFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leert die Komponente und zeigt den neutralen Platzhaltertext.
|
||||
* Das aktuell geöffnete PDF-Dokument wird asynchron auf dem Worker-Thread geschlossen.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*/
|
||||
public void clear() {
|
||||
currentSourceFile = null;
|
||||
currentPage = 0;
|
||||
totalPages = -1;
|
||||
pageCache.clear();
|
||||
// Neue Sequenznummer: laufende Requests werden verworfen
|
||||
currentRequestSequence.incrementAndGet();
|
||||
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
|
||||
executor.submit(this::closeCurrentDocumentOnWorker);
|
||||
imageView.setImage(null);
|
||||
showPlaceholder();
|
||||
updateNavigationButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktiviert oder deaktiviert die Navigations-Buttons.
|
||||
* Während eines laufenden Batch-Laufs soll die Navigation deaktiviert sein.
|
||||
* Die Vorschau-Anzeige bleibt sichtbar.
|
||||
*
|
||||
* @param enabled {@code true} wenn Navigation erlaubt ist
|
||||
*/
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
updateNavigationButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendet den internen Executor sauber und schließt das eventuell noch offene
|
||||
* PDF-Dokument. Muss beim Schließen der Anwendung aufgerufen werden.
|
||||
*/
|
||||
public void shutdown() {
|
||||
try {
|
||||
executor.submit(this::closeCurrentDocumentOnWorker);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Executor wurde bereits beendet – keine Aktion erforderlich
|
||||
}
|
||||
executor.shutdown();
|
||||
}
|
||||
|
||||
// --- Test-Accessoren ------------------------------------------------------
|
||||
|
||||
/** Visible for tests. */
|
||||
Label overlayLabel() {
|
||||
return overlayLabel;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
Button prevButton() {
|
||||
return prevButton;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
Button nextButton() {
|
||||
return nextButton;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
Label pageLabel() {
|
||||
return pageLabel;
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
ProgressIndicator progressIndicator() {
|
||||
return progressIndicator;
|
||||
}
|
||||
|
||||
// --- Navigation -----------------------------------------------------------
|
||||
|
||||
private void navigateToPreviousPage() {
|
||||
if (!enabled || currentPage <= 1) {
|
||||
return;
|
||||
}
|
||||
goToPage(currentPage - 1);
|
||||
}
|
||||
|
||||
private void navigateToNextPage() {
|
||||
if (!enabled || totalPages <= 0 || currentPage >= totalPages) {
|
||||
return;
|
||||
}
|
||||
goToPage(currentPage + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wechselt zur angegebenen Seite. Bereits gerenderte Seiten werden direkt aus dem
|
||||
* Cache angezeigt; ansonsten wird ein Rendering-Auftrag auf den Worker-Thread gelegt.
|
||||
*
|
||||
* @param targetPage Ziel-Seite (1-basiert, muss im gültigen Bereich liegen)
|
||||
*/
|
||||
private void goToPage(int targetPage) {
|
||||
currentPage = targetPage;
|
||||
updatePageLabel();
|
||||
updateNavigationButtons();
|
||||
|
||||
Image cached = pageCache.get(targetPage);
|
||||
if (cached != null) {
|
||||
imageView.setImage(cached);
|
||||
showContent();
|
||||
return;
|
||||
}
|
||||
|
||||
long seq = currentRequestSequence.incrementAndGet();
|
||||
showLoading();
|
||||
executor.submit(() -> renderPageOnWorker(targetPage, seq));
|
||||
}
|
||||
|
||||
// --- Asynchrones Laden und Rendering --------------------------------------
|
||||
|
||||
/**
|
||||
* Startet eine asynchrone Lade-Anforderung für die angegebene Datei.
|
||||
* Erhöht die Sequenznummer, damit veraltete Ergebnisse erkannt und verworfen werden.
|
||||
*
|
||||
* @param file die zu ladende Quelldatei
|
||||
*/
|
||||
private void requestLoad(Path file) {
|
||||
long seq = currentRequestSequence.incrementAndGet();
|
||||
LOG.debug("PDF-Vorschau: Lade {} (Anforderung #{})", file, seq);
|
||||
|
||||
showLoading();
|
||||
updateNavigationButtons();
|
||||
|
||||
executor.submit(() -> loadAndRenderFirstPageOnWorker(file, seq));
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet die PDF-Datei, ermittelt die Seitenzahl und rendert die erste Seite.
|
||||
* Läuft ausschließlich auf dem Worker-Thread.
|
||||
*
|
||||
* @param file die zu ladende Datei
|
||||
* @param seq die Sequenznummer dieser Anforderung
|
||||
*/
|
||||
private void loadAndRenderFirstPageOnWorker(Path file, long seq) {
|
||||
File ioFile = file.toFile();
|
||||
|
||||
if (!ioFile.exists()) {
|
||||
LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen – Datei nicht gefunden: {}", file);
|
||||
publishError(seq, FILE_NOT_FOUND_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vorheriges Dokument schließen bevor ein neues geöffnet wird
|
||||
closeCurrentDocumentOnWorker();
|
||||
|
||||
try {
|
||||
PDDocument doc = Loader.loadPDF(ioFile);
|
||||
currentDocument = doc;
|
||||
currentRenderer = new PDFRenderer(doc);
|
||||
|
||||
int pages = Math.max(1, doc.getNumberOfPages());
|
||||
BufferedImage buffered =
|
||||
currentRenderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
|
||||
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
|
||||
|
||||
final int totalPagesFinal = pages;
|
||||
Platform.runLater(() -> {
|
||||
if (currentRequestSequence.get() != seq) {
|
||||
return; // Veraltet – verwerfen
|
||||
}
|
||||
totalPages = totalPagesFinal;
|
||||
currentPage = 1;
|
||||
pageCache.put(1, fxImage);
|
||||
imageView.setImage(fxImage);
|
||||
showContent();
|
||||
updateNavigationButtons();
|
||||
updatePageLabel();
|
||||
LOG.debug("PDF-Vorschau: Rendering abgeschlossen – {} Seite(n)", totalPagesFinal);
|
||||
});
|
||||
} catch (InvalidPasswordException ipe) {
|
||||
LOG.warn("PDF-Vorschau: PDF ist passwortgeschützt: {}", file, ipe);
|
||||
closeCurrentDocumentOnWorker();
|
||||
publishError(seq, PDF_PASSWORD_PROTECTED_TEXT);
|
||||
} catch (Exception e) {
|
||||
LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen: {}", file, e);
|
||||
closeCurrentDocumentOnWorker();
|
||||
publishError(seq, PDF_UNREADABLE_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert eine einzelne Seite des aktuell geöffneten Dokuments.
|
||||
* Läuft ausschließlich auf dem Worker-Thread.
|
||||
*
|
||||
* @param page 1-basierte Seitennummer
|
||||
* @param seq die Sequenznummer dieser Anforderung
|
||||
*/
|
||||
private void renderPageOnWorker(int page, long seq) {
|
||||
PDFRenderer renderer = currentRenderer;
|
||||
if (renderer == null) {
|
||||
// Dokument wurde zwischenzeitlich geschlossen – nichts zu tun
|
||||
return;
|
||||
}
|
||||
try {
|
||||
BufferedImage buffered = renderer.renderImageWithDPI(page - 1, RENDER_DPI, ImageType.RGB);
|
||||
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
|
||||
Platform.runLater(() -> {
|
||||
if (currentRequestSequence.get() != seq) {
|
||||
return; // Veraltet – verwerfen
|
||||
}
|
||||
pageCache.put(page, fxImage);
|
||||
if (currentPage == page) {
|
||||
imageView.setImage(fxImage);
|
||||
showContent();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOG.warn("PDF-Vorschau: Rendering von Seite {} fehlgeschlagen", page, e);
|
||||
publishError(seq, PDF_UNREADABLE_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schließt das aktuell geöffnete PDF-Dokument, falls vorhanden. Läuft ausschließlich
|
||||
* auf dem Worker-Thread und ist idempotent.
|
||||
*/
|
||||
private void closeCurrentDocumentOnWorker() {
|
||||
PDDocument doc = currentDocument;
|
||||
currentDocument = null;
|
||||
currentRenderer = null;
|
||||
if (doc != null) {
|
||||
try {
|
||||
doc.close();
|
||||
} catch (Exception e) {
|
||||
LOG.debug("PDF-Vorschau: Schließen des Dokuments schlug fehl", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Übergibt eine Fehlermeldung auf den FX-Thread. Veraltete Meldungen werden verworfen.
|
||||
*
|
||||
* @param seq Sequenznummer der Anforderung, zu der die Meldung gehört
|
||||
* @param message anzuzeigende Fehlermeldung
|
||||
*/
|
||||
private void publishError(long seq, String message) {
|
||||
Platform.runLater(() -> {
|
||||
if (currentRequestSequence.get() != seq) {
|
||||
return;
|
||||
}
|
||||
showError(message);
|
||||
updateNavigationButtons();
|
||||
});
|
||||
}
|
||||
|
||||
// --- UI-Zustandshelfer ---------------------------------------------------
|
||||
|
||||
private void showPlaceholder() {
|
||||
overlayLabel.setText(PLACEHOLDER_TEXT);
|
||||
overlayLabel.setVisible(true);
|
||||
overlayLabel.setManaged(true);
|
||||
imageView.setVisible(false);
|
||||
imageView.setManaged(false);
|
||||
progressIndicator.setVisible(false);
|
||||
progressIndicator.setManaged(false);
|
||||
pageLabel.setText("");
|
||||
}
|
||||
|
||||
private void showLoading() {
|
||||
progressIndicator.setVisible(true);
|
||||
progressIndicator.setManaged(true);
|
||||
overlayLabel.setVisible(false);
|
||||
overlayLabel.setManaged(false);
|
||||
imageView.setVisible(false);
|
||||
imageView.setManaged(false);
|
||||
}
|
||||
|
||||
private void showContent() {
|
||||
progressIndicator.setVisible(false);
|
||||
progressIndicator.setManaged(false);
|
||||
overlayLabel.setVisible(false);
|
||||
overlayLabel.setManaged(false);
|
||||
imageView.setVisible(true);
|
||||
imageView.setManaged(true);
|
||||
}
|
||||
|
||||
private void showError(String message) {
|
||||
overlayLabel.setText(message);
|
||||
overlayLabel.setVisible(true);
|
||||
overlayLabel.setManaged(true);
|
||||
imageView.setVisible(false);
|
||||
imageView.setManaged(false);
|
||||
progressIndicator.setVisible(false);
|
||||
progressIndicator.setManaged(false);
|
||||
pageLabel.setText("");
|
||||
}
|
||||
|
||||
private void updateNavigationButtons() {
|
||||
boolean canNavigate = enabled && currentSourceFile != null && totalPages > 0;
|
||||
prevButton.setDisable(!canNavigate || currentPage <= 1);
|
||||
nextButton.setDisable(!canNavigate || currentPage >= totalPages);
|
||||
}
|
||||
|
||||
private void updatePageLabel() {
|
||||
if (totalPages > 0 && currentPage > 0) {
|
||||
pageLabel.setText("Seite " + currentPage + " / " + totalPages);
|
||||
} else {
|
||||
pageLabel.setText("");
|
||||
}
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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 darf nicht null sein");
|
||||
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 darf nicht null sein");
|
||||
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 darf nicht null sein");
|
||||
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 darf nicht null sein");
|
||||
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 darf nicht null sein");
|
||||
return new StatusVisuals(
|
||||
iconFor(status),
|
||||
cssColorFor(status),
|
||||
tooltipFor(status),
|
||||
summaryCategoryFor(status));
|
||||
}
|
||||
|
||||
/** Nicht instanziierbar – reine Utility-Klasse. */
|
||||
private ProcessingStatusPresentation() {
|
||||
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Inbound adapter components that drive the GUI's processing-run tab.
|
||||
* <p>
|
||||
* The classes in this package build the second tab of the main window, translate
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
|
||||
* callbacks into JavaFX UI updates, and manage the worker thread that executes a
|
||||
* single run against a stored {@code .properties} configuration.
|
||||
*
|
||||
* <h2>Threading contract</h2>
|
||||
* <p>
|
||||
* The batch run itself always executes on a dedicated background worker thread obtained
|
||||
* from {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}.
|
||||
* Every UI mutation (progress bar value, result rows, button states, tab sperre) is
|
||||
* dispatched onto the JavaFX Application Thread via {@code Platform.runLater}. No class
|
||||
* in this package mutates a JavaFX {@code Control} from the worker thread.
|
||||
*
|
||||
* <h2>Cancellation</h2>
|
||||
* <p>
|
||||
* The coordinator exposes a soft-stop cancellation hook: setting the cancellation flag
|
||||
* causes the use case to stop <em>before</em> starting the next candidate; the candidate
|
||||
* currently being processed is always completed in full so the SQLite persistence remains
|
||||
* consistent.
|
||||
*
|
||||
* <h2>Configuration source</h2>
|
||||
* <p>
|
||||
* A run is always started against the {@code .properties} file currently on disk (the
|
||||
* last saved state of the editor). Unsaved editor content is intentionally not forwarded
|
||||
* to the launcher — the run must match what a parallel headless launch would see.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||
+1
@@ -92,6 +92,7 @@ public final class GuiApiKeyMerger {
|
||||
current.maxRetriesTransient(),
|
||||
current.maxPages(),
|
||||
current.maxTextCharacters(),
|
||||
current.maxTitleLength(),
|
||||
current.logAiSensitive(),
|
||||
current.activeProviderFamily(),
|
||||
merged);
|
||||
|
||||
+2
@@ -25,6 +25,7 @@ public final class GuiConfigurationEditorStateFactory {
|
||||
private static final String PROP_MAX_RETRIES_TRANSIENT = "max.retries.transient";
|
||||
private static final String PROP_MAX_PAGES = "max.pages";
|
||||
private static final String PROP_MAX_TEXT_CHARACTERS = "max.text.characters";
|
||||
private static final String PROP_MAX_TITLE_LENGTH = "max.title.length";
|
||||
private static final String PROP_LOG_AI_SENSITIVE = "log.ai.sensitive";
|
||||
private static final String PROP_ACTIVE_PROVIDER = "ai.provider.active";
|
||||
private static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
|
||||
@@ -74,6 +75,7 @@ public final class GuiConfigurationEditorStateFactory {
|
||||
propertyOrBlank(properties, PROP_MAX_RETRIES_TRANSIENT),
|
||||
propertyOrBlank(properties, PROP_MAX_PAGES),
|
||||
propertyOrBlank(properties, PROP_MAX_TEXT_CHARACTERS),
|
||||
propertyOrBlank(properties, PROP_MAX_TITLE_LENGTH),
|
||||
propertyOrBlank(properties, PROP_LOG_AI_SENSITIVE),
|
||||
propertyOrBlank(properties, PROP_ACTIVE_PROVIDER),
|
||||
providerConfigurations);
|
||||
|
||||
+22
-6
@@ -23,7 +23,8 @@ public final class GuiConfigurationTemplateFactory {
|
||||
private static final String LOG_LEVEL = "INFO";
|
||||
private static final String MAX_RETRIES_TRANSIENT = "3";
|
||||
private static final String MAX_PAGES = "10";
|
||||
private static final String MAX_TEXT_CHARACTERS = "5000";
|
||||
private static final String MAX_TEXT_CHARACTERS = "1000";
|
||||
private static final String DEFAULT_MAX_TITLE_LENGTH = "60";
|
||||
|
||||
private static final String OPENAI_BASE_URL = "https://api.openai.com/v1";
|
||||
private static final String OPENAI_MODEL = "gpt-4o-mini";
|
||||
@@ -48,15 +49,28 @@ public final class GuiConfigurationTemplateFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the empty editor state used when the GUI starts without a loaded configuration.
|
||||
* Creates the editor state used when the GUI starts without a loaded configuration.
|
||||
* <p>
|
||||
* This start state intentionally does not show the standard template yet. The template
|
||||
* is reserved for the explicit {@code Neu} action so the GUI starts without an implicit
|
||||
* draft and only shows the welcome guidance until the user requests a new configuration.
|
||||
* The start state contains the standard configuration template so the GUI shows the
|
||||
* default values immediately, equivalent to the explicit {@code Neu} action having been
|
||||
* triggered. No file snapshot is associated with the state.
|
||||
*
|
||||
* @return a clean editor state without a loaded file snapshot and without template values
|
||||
* @return a clean editor state with the standard template values and no loaded file snapshot
|
||||
*/
|
||||
public static GuiConfigurationEditorState createBlankStartState() {
|
||||
return createStandardTemplate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a truly empty editor state without any template values.
|
||||
* <p>
|
||||
* This factory is reserved for tests that intentionally need an editor state with empty
|
||||
* field values and no provider configurations. Production startup uses
|
||||
* {@link #createBlankStartState()} which returns the standard template instead.
|
||||
*
|
||||
* @return a clean editor state without any template values
|
||||
*/
|
||||
public static GuiConfigurationEditorState createEmptyStartState() {
|
||||
GuiConfigurationValues blankValues = new GuiConfigurationValues(
|
||||
"",
|
||||
"",
|
||||
@@ -70,6 +84,7 @@ public final class GuiConfigurationTemplateFactory {
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
Map.of());
|
||||
return new GuiConfigurationEditorState(Optional.empty(), blankValues, blankValues, Optional.empty());
|
||||
}
|
||||
@@ -103,6 +118,7 @@ public final class GuiConfigurationTemplateFactory {
|
||||
MAX_RETRIES_TRANSIENT,
|
||||
MAX_PAGES,
|
||||
MAX_TEXT_CHARACTERS,
|
||||
DEFAULT_MAX_TITLE_LENGTH,
|
||||
Boolean.toString(false),
|
||||
AiProviderFamily.CLAUDE.getIdentifier(),
|
||||
providerConfigurations);
|
||||
|
||||
+29
-13
@@ -23,6 +23,7 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
* @param maxRetriesTransient transient retry limit as editable text
|
||||
* @param maxPages page limit as editable text
|
||||
* @param maxTextCharacters text limit as editable text
|
||||
* @param maxTitleLength maximum base-title length as editable text
|
||||
* @param logAiSensitive raw value of {@code log.ai.sensitive} as editable text
|
||||
* @param activeProviderFamily raw value of {@code ai.provider.active} as editable text
|
||||
* @param providerConfigurations provider-specific editor state keyed by provider family
|
||||
@@ -38,6 +39,7 @@ public record GuiConfigurationValues(
|
||||
String maxRetriesTransient,
|
||||
String maxPages,
|
||||
String maxTextCharacters,
|
||||
String maxTitleLength,
|
||||
String logAiSensitive,
|
||||
String activeProviderFamily,
|
||||
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
|
||||
@@ -55,6 +57,7 @@ public record GuiConfigurationValues(
|
||||
* @param maxRetriesTransient transient retry limit; {@code null} becomes an empty string
|
||||
* @param maxPages page limit; {@code null} becomes an empty string
|
||||
* @param maxTextCharacters text limit; {@code null} becomes an empty string
|
||||
* @param maxTitleLength maximum base-title length; {@code null} becomes an empty string
|
||||
* @param logAiSensitive raw {@code log.ai.sensitive} value; {@code null} becomes an empty string
|
||||
* @param activeProviderFamily raw {@code ai.provider.active} value; {@code null} becomes an empty string
|
||||
* @param providerConfigurations provider-specific state map; must not be {@code null}
|
||||
@@ -70,6 +73,7 @@ public record GuiConfigurationValues(
|
||||
maxRetriesTransient = normalizeText(maxRetriesTransient);
|
||||
maxPages = normalizeText(maxPages);
|
||||
maxTextCharacters = normalizeText(maxTextCharacters);
|
||||
maxTitleLength = normalizeText(maxTitleLength);
|
||||
logAiSensitive = normalizeText(logAiSensitive);
|
||||
activeProviderFamily = normalizeText(activeProviderFamily);
|
||||
|
||||
@@ -98,7 +102,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withActiveProviderFamily(String providerFamily) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, providerFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, providerFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +114,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withSourceFolder(String value) {
|
||||
return new GuiConfigurationValues(value, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,7 +126,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withTargetFolder(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, value, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +138,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withSqliteFile(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, value, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +150,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withPromptTemplateFile(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, value,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +162,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withRuntimeLockFile(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
value, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,7 +174,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withLogDirectory(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, value, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +186,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withLogLevel(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, value, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +198,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withMaxRetriesTransient(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, value, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,7 +210,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withMaxPages(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, value, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,7 +222,19 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withMaxTextCharacters(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, value,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with a different maximum base-title length value.
|
||||
*
|
||||
* @param value new value; {@code null} becomes an empty string
|
||||
* @return a new configuration values object with the requested title-length value
|
||||
*/
|
||||
public GuiConfigurationValues withMaxTitleLength(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
value, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,7 +246,7 @@ public record GuiConfigurationValues(
|
||||
public GuiConfigurationValues withLogAiSensitive(String value) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
value, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, value, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +259,7 @@ public record GuiConfigurationValues(
|
||||
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
|
||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+24
@@ -4,10 +4,13 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.Duration;
|
||||
|
||||
/**
|
||||
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
|
||||
@@ -65,6 +68,7 @@ public final class GuiModelFieldContainer extends StackPane {
|
||||
|
||||
// Initial state: show text field (NOT_YET_LOADED → manual input)
|
||||
applyVisibility(false);
|
||||
setAlignment(Pos.CENTER_LEFT);
|
||||
getChildren().addAll(comboBox, textField);
|
||||
}
|
||||
|
||||
@@ -167,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);
|
||||
}
|
||||
+794
@@ -0,0 +1,794 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.SelectionMode;
|
||||
import javafx.scene.control.SplitPane;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TableCell;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Dritter Haupt-Tab des JavaFX-Editorfensters: der Historien-Tab „Verlauf".
|
||||
* <p>
|
||||
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
|
||||
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
|
||||
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
|
||||
*
|
||||
* <h2>Layout</h2>
|
||||
* <pre>
|
||||
* ┌─────────────────────────────────────────────────────────────────┐
|
||||
* │ [ Suchfeld ] [ Status ▾ ] [ Aktualisieren ] │
|
||||
* ├────────────────────────┬────────────────────────────────────────┤
|
||||
* │ Dokumentenliste (~55%) │ Detailbereich (~45%) │
|
||||
* │ │ Dokument-Info │
|
||||
* │ │ Versuche-Tabelle │
|
||||
* │ │ KI-Begründung │
|
||||
* ├────────────────────────┴────────────────────────────────────────┤
|
||||
* │ [ Status zurücksetzen ] [ Eintrag löschen ] Statuszeile │
|
||||
* └─────────────────────────────────────────────────────────────────┘
|
||||
* </pre>
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* <p>Alle DB-Zugriffe laufen auf einem Hintergrund-Worker-Thread.
|
||||
* UI-Updates erfolgen ausschließlich via {@code Platform.runLater()}.
|
||||
* Destruktive Aktionen (Reset, Löschen) sind während eines aktiven
|
||||
* Verarbeitungslaufs deaktiviert.
|
||||
*/
|
||||
public final class GuiHistoryTab {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class);
|
||||
|
||||
private static final String TAB_TITLE = "Verlauf";
|
||||
private static final String EMPTY_DB_TEXT = "Noch keine Verarbeitungen vorhanden.";
|
||||
private static final String TOO_MANY_RESULTS_TEXT =
|
||||
"Weitere Einträge vorhanden – Filter verwenden um die Trefferliste einzuschränken.";
|
||||
private static final String DETAIL_PLACEHOLDER = "Dokument auswählen für Details";
|
||||
private static final String NO_REASONING_TEXT = "Kein KI-Reasoning für diesen Versuch vorhanden.";
|
||||
private static final String LOADING_TEXT = "Wird geladen …";
|
||||
private static final String LAUF_AKTIV_HINWEIS = "Aktion während Verarbeitungslauf nicht möglich.";
|
||||
|
||||
private static final DateTimeFormatter TIMESTAMP_FMT =
|
||||
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm").withZone(ZoneId.systemDefault());
|
||||
|
||||
// ---- Bridge-Ports ---------------------------------------------------
|
||||
private final GuiHistoryOverviewPort overviewPort;
|
||||
private final GuiHistoryDetailsPort detailsPort;
|
||||
private final GuiHistoryResetDocumentStatusPort resetPort;
|
||||
private final GuiDeleteDocumentHistoryPort deletePort;
|
||||
private final BooleanSupplier runningCheck;
|
||||
/** Liefert den Pfad zur aktuell geladenen Konfigurationsdatei, oder {@code null} wenn keine geladen. */
|
||||
private final Supplier<Path> configPathSupplier;
|
||||
|
||||
// ---- JavaFX-Knoten --------------------------------------------------
|
||||
private final Tab tab = new Tab(TAB_TITLE);
|
||||
|
||||
private final TextField searchField = new TextField();
|
||||
private final ComboBox<String> statusFilterBox = new ComboBox<>();
|
||||
private final Button refreshButton = new Button("Aktualisieren");
|
||||
|
||||
private final TableView<DocumentHistoryRow> overviewTable = new TableView<>();
|
||||
private final ObservableList<DocumentHistoryRow> overviewItems = FXCollections.observableArrayList();
|
||||
|
||||
private final Label statusBarLabel = new Label();
|
||||
private final Label moreThanMaxLabel = new Label();
|
||||
|
||||
// Detailbereich
|
||||
private final GridPane detailGrid = new GridPane();
|
||||
private final Label detailFingerprintLabel = new Label();
|
||||
private final Label detailSourceFileLabel = new Label();
|
||||
private final Label detailSourcePathLabel = new Label();
|
||||
private final Label detailStatusLabel = new Label();
|
||||
private final Label detailCreatedLabel = new Label();
|
||||
private final Label detailUpdatedLabel = new Label();
|
||||
|
||||
private final TableView<ProcessingAttempt> attemptsTable = new TableView<>();
|
||||
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
|
||||
private final TextArea reasoningArea = new TextArea();
|
||||
|
||||
private final Button resetButton = new Button("Status zurücksetzen");
|
||||
private final Button deleteButton = new Button("Eintrag löschen");
|
||||
|
||||
// ---- Zustand --------------------------------------------------------
|
||||
private final ExecutorService workerPool;
|
||||
|
||||
/**
|
||||
* Erzeugt den Historien-Tab.
|
||||
*
|
||||
* @param overviewPort Brücke zur Dokumentenübersicht; darf nicht {@code null} sein
|
||||
* @param detailsPort Brücke zur Detailansicht; darf nicht {@code null} sein
|
||||
* @param resetPort Brücke zum feldgenauen Status-Reset; darf nicht {@code null} sein
|
||||
* @param deletePort Brücke zum vollständigen Löschen; darf nicht {@code null} sein
|
||||
* @param runningCheck Liefert {@code true} wenn gerade ein Verarbeitungslauf aktiv ist;
|
||||
* darf nicht {@code null} sein
|
||||
* @param configPathSupplier Liefert den Pfad zur aktuell geladenen Konfigurationsdatei,
|
||||
* oder {@code null} wenn keine geladen ist; darf nicht {@code null} sein
|
||||
*/
|
||||
public GuiHistoryTab(
|
||||
GuiHistoryOverviewPort overviewPort,
|
||||
GuiHistoryDetailsPort detailsPort,
|
||||
GuiHistoryResetDocumentStatusPort resetPort,
|
||||
GuiDeleteDocumentHistoryPort deletePort,
|
||||
BooleanSupplier runningCheck,
|
||||
Supplier<Path> configPathSupplier) {
|
||||
this.overviewPort = Objects.requireNonNull(overviewPort, "overviewPort darf nicht null sein");
|
||||
this.detailsPort = Objects.requireNonNull(detailsPort, "detailsPort darf nicht null sein");
|
||||
this.resetPort = Objects.requireNonNull(resetPort, "resetPort darf nicht null sein");
|
||||
this.deletePort = Objects.requireNonNull(deletePort, "deletePort darf nicht null sein");
|
||||
this.runningCheck = Objects.requireNonNull(runningCheck, "runningCheck darf nicht null sein");
|
||||
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier darf nicht null sein");
|
||||
|
||||
this.workerPool = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "HistoryTabWorker");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
buildUi();
|
||||
wireEvents();
|
||||
tab.setClosable(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den JavaFX-{@link Tab}, der in die TabPane eingefügt werden kann.
|
||||
*
|
||||
* @return der Tab; nie {@code null}
|
||||
*/
|
||||
public Tab tab() {
|
||||
return tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Dokumentenübersicht neu – muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
* Wird vom Tab-Wechsel-Listener ausgelöst.
|
||||
*/
|
||||
public void refresh() {
|
||||
loadOverview();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UI-Aufbau
|
||||
// =========================================================================
|
||||
|
||||
private void buildUi() {
|
||||
// --- Toolbar ---
|
||||
searchField.setPromptText("Suche nach Dateiname …");
|
||||
searchField.setPrefWidth(300);
|
||||
Tooltip.install(searchField, new Tooltip(
|
||||
"Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal)."));
|
||||
|
||||
statusFilterBox.getItems().add("Alle Status");
|
||||
for (ProcessingStatus s : ProcessingStatus.values()) {
|
||||
statusFilterBox.getItems().add(s.name());
|
||||
}
|
||||
statusFilterBox.getSelectionModel().selectFirst();
|
||||
Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen."));
|
||||
|
||||
refreshButton.setTooltip(new Tooltip("Dokumentenliste neu aus der Datenbank laden."));
|
||||
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
|
||||
HBox toolbar = new HBox(8, searchField, statusFilterBox, spacer, refreshButton);
|
||||
toolbar.setAlignment(Pos.CENTER_LEFT);
|
||||
toolbar.setPadding(new Insets(6, 8, 6, 8));
|
||||
|
||||
// --- Dokumentenliste (links) ---
|
||||
buildOverviewTable();
|
||||
|
||||
moreThanMaxLabel.setStyle("-fx-text-fill: #d98200; -fx-font-style: italic;");
|
||||
moreThanMaxLabel.setVisible(false);
|
||||
moreThanMaxLabel.setManaged(false);
|
||||
|
||||
VBox leftPane = new VBox(4, overviewTable, moreThanMaxLabel);
|
||||
VBox.setVgrow(overviewTable, Priority.ALWAYS);
|
||||
leftPane.setPadding(new Insets(0, 4, 0, 0));
|
||||
|
||||
// --- Detailbereich (rechts) ---
|
||||
VBox rightPane = buildDetailPane();
|
||||
|
||||
// --- SplitPane ---
|
||||
SplitPane splitPane = new SplitPane(leftPane, rightPane);
|
||||
splitPane.setDividerPositions(0.55);
|
||||
|
||||
// --- Aktionsleiste unten ---
|
||||
resetButton.setTooltip(new Tooltip(
|
||||
"Setzt Status, Fehlerzähler und letzten Fehlerzeitpunkt zurück. "
|
||||
+ "Versuche bleiben erhalten. Das Dokument wird beim nächsten Lauf erneut verarbeitet."));
|
||||
deleteButton.setTooltip(new Tooltip(
|
||||
"Löscht den Eintrag und alle Versuche vollständig. "
|
||||
+ "Diese Aktion ist nicht rückgängig zu machen."));
|
||||
resetButton.setDisable(true);
|
||||
deleteButton.setDisable(true);
|
||||
|
||||
statusBarLabel.setStyle("-fx-text-fill: #555555; -fx-font-style: italic;");
|
||||
|
||||
HBox actionBar = new HBox(8, resetButton, deleteButton, spacerNew(), statusBarLabel);
|
||||
actionBar.setAlignment(Pos.CENTER_LEFT);
|
||||
actionBar.setPadding(new Insets(6, 8, 6, 8));
|
||||
|
||||
// --- Gesamtlayout ---
|
||||
BorderPane content = new BorderPane();
|
||||
content.setTop(toolbar);
|
||||
content.setCenter(splitPane);
|
||||
content.setBottom(actionBar);
|
||||
BorderPane.setMargin(toolbar, Insets.EMPTY);
|
||||
|
||||
tab.setContent(content);
|
||||
}
|
||||
|
||||
private void buildOverviewTable() {
|
||||
overviewTable.setItems(overviewItems);
|
||||
overviewTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
|
||||
overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
||||
|
||||
// Status-Icon-Spalte
|
||||
TableColumn<DocumentHistoryRow, String> statusCol = new TableColumn<>("Status");
|
||||
statusCol.setCellValueFactory(cell ->
|
||||
new SimpleStringProperty(statusIcon(cell.getValue().overallStatus())));
|
||||
statusCol.setCellFactory(col -> new TableCell<>() {
|
||||
@Override
|
||||
protected void updateItem(String icon, boolean empty) {
|
||||
super.updateItem(icon, empty);
|
||||
if (empty || icon == null) {
|
||||
setText(null);
|
||||
setTooltip(null);
|
||||
} else {
|
||||
setText(icon);
|
||||
DocumentHistoryRow row = getTableView().getItems().get(getIndex());
|
||||
setStyle("-fx-text-fill: " + statusColor(row.overallStatus()) + "; -fx-font-weight: bold;");
|
||||
setTooltip(new Tooltip(statusTooltip(row.overallStatus())));
|
||||
}
|
||||
}
|
||||
});
|
||||
statusCol.setPrefWidth(60);
|
||||
statusCol.setMaxWidth(70);
|
||||
|
||||
// Quelldateiname
|
||||
TableColumn<DocumentHistoryRow, String> sourceCol = new TableColumn<>("Quelldatei");
|
||||
sourceCol.setCellValueFactory(cell ->
|
||||
new SimpleStringProperty(cell.getValue().sourceFileName()));
|
||||
sourceCol.setCellFactory(col -> ellipsisCell());
|
||||
|
||||
// Zieldateiname
|
||||
TableColumn<DocumentHistoryRow, String> targetCol = new TableColumn<>("Zieldatei");
|
||||
targetCol.setCellValueFactory(cell ->
|
||||
new SimpleStringProperty(
|
||||
cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : "—"));
|
||||
targetCol.setCellFactory(col -> ellipsisCell());
|
||||
|
||||
// Letzter Versuch
|
||||
TableColumn<DocumentHistoryRow, String> updatedCol = new TableColumn<>("Letzter Versuch");
|
||||
updatedCol.setCellValueFactory(cell ->
|
||||
new SimpleStringProperty(formatInstant(cell.getValue().updatedAt())));
|
||||
updatedCol.setPrefWidth(140);
|
||||
updatedCol.setMaxWidth(160);
|
||||
|
||||
// Anzahl Versuche
|
||||
TableColumn<DocumentHistoryRow, String> countCol = new TableColumn<>("Versuche");
|
||||
countCol.setCellValueFactory(cell ->
|
||||
new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount())));
|
||||
countCol.setPrefWidth(70);
|
||||
countCol.setMaxWidth(80);
|
||||
|
||||
overviewTable.getColumns().setAll(statusCol, sourceCol, targetCol, updatedCol, countCol);
|
||||
}
|
||||
|
||||
private VBox buildDetailPane() {
|
||||
// Dokument-Info
|
||||
detailGrid.setHgap(8);
|
||||
detailGrid.setVgap(4);
|
||||
detailGrid.setPadding(new Insets(8));
|
||||
|
||||
addDetailRow(0, "Fingerprint:", detailFingerprintLabel);
|
||||
addDetailRow(1, "Quelldatei:", detailSourceFileLabel);
|
||||
addDetailRow(2, "Quellpfad:", detailSourcePathLabel);
|
||||
addDetailRow(3, "Status:", detailStatusLabel);
|
||||
addDetailRow(4, "Erstellt:", detailCreatedLabel);
|
||||
addDetailRow(5, "Aktualisiert:", detailUpdatedLabel);
|
||||
|
||||
Label detailTitle = new Label("Dokument-Details");
|
||||
detailTitle.setStyle("-fx-font-weight: bold;");
|
||||
|
||||
// Versuche-Tabelle
|
||||
buildAttemptsTable();
|
||||
Label attemptsTitle = new Label("Verarbeitungsversuche");
|
||||
attemptsTitle.setStyle("-fx-font-weight: bold;");
|
||||
|
||||
// KI-Begründung
|
||||
reasoningArea.setEditable(false);
|
||||
reasoningArea.setWrapText(true);
|
||||
reasoningArea.setPrefRowCount(4);
|
||||
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
|
||||
reasoningTitle.setStyle("-fx-font-weight: bold;");
|
||||
|
||||
VBox rightPane = new VBox(8,
|
||||
detailTitle, detailGrid,
|
||||
attemptsTitle, attemptsTable,
|
||||
reasoningTitle, reasoningArea);
|
||||
rightPane.setPadding(new Insets(4, 8, 4, 4));
|
||||
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
|
||||
|
||||
ScrollPane scroll = new ScrollPane(rightPane);
|
||||
scroll.setFitToWidth(true);
|
||||
scroll.setFitToHeight(true);
|
||||
|
||||
VBox wrapper = new VBox(scroll);
|
||||
VBox.setVgrow(scroll, Priority.ALWAYS);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private void buildAttemptsTable() {
|
||||
attemptsTable.setItems(attemptsItems);
|
||||
attemptsTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||
attemptsTable.setPlaceholder(new Label("Keine Versuche vorhanden."));
|
||||
attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
||||
attemptsTable.setPrefHeight(150);
|
||||
|
||||
TableColumn<ProcessingAttempt, String> numCol = new TableColumn<>("#");
|
||||
numCol.setCellValueFactory(c ->
|
||||
new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber())));
|
||||
numCol.setPrefWidth(40);
|
||||
numCol.setMaxWidth(50);
|
||||
|
||||
TableColumn<ProcessingAttempt, String> dateCol = new TableColumn<>("Datum");
|
||||
dateCol.setCellValueFactory(c ->
|
||||
new SimpleStringProperty(formatInstant(c.getValue().endedAt())));
|
||||
dateCol.setPrefWidth(130);
|
||||
dateCol.setMaxWidth(150);
|
||||
|
||||
TableColumn<ProcessingAttempt, String> statusCol = new TableColumn<>("Status");
|
||||
statusCol.setCellValueFactory(c ->
|
||||
new SimpleStringProperty(
|
||||
statusIcon(c.getValue().status()) + " " + c.getValue().status().name()));
|
||||
statusCol.setPrefWidth(140);
|
||||
|
||||
TableColumn<ProcessingAttempt, String> providerCol = new TableColumn<>("Provider");
|
||||
providerCol.setCellValueFactory(c ->
|
||||
new SimpleStringProperty(
|
||||
c.getValue().aiProvider() != null ? c.getValue().aiProvider() : "—"));
|
||||
providerCol.setPrefWidth(90);
|
||||
|
||||
TableColumn<ProcessingAttempt, String> modelCol = new TableColumn<>("Modell");
|
||||
modelCol.setCellValueFactory(c ->
|
||||
new SimpleStringProperty(
|
||||
c.getValue().modelName() != null ? c.getValue().modelName() : "—"));
|
||||
modelCol.setCellFactory(col -> ellipsisCell());
|
||||
|
||||
TableColumn<ProcessingAttempt, String> fileNameCol = new TableColumn<>("Vorgeschlagener Name");
|
||||
fileNameCol.setCellValueFactory(c ->
|
||||
new SimpleStringProperty(
|
||||
c.getValue().finalTargetFileName() != null
|
||||
? c.getValue().finalTargetFileName() : "—"));
|
||||
fileNameCol.setCellFactory(col -> ellipsisCell());
|
||||
|
||||
attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol, fileNameCol);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Event-Verdrahtung
|
||||
// =========================================================================
|
||||
|
||||
private void wireEvents() {
|
||||
refreshButton.setOnAction(e -> loadOverview());
|
||||
|
||||
// Debounce-artige Aktualisierung bei Texteingabe: direkte Suche bei Enter,
|
||||
// sonst über Fokus-Verlust oder expliziten Aktualisieren-Button
|
||||
searchField.setOnAction(e -> loadOverview());
|
||||
|
||||
statusFilterBox.setOnAction(e -> loadOverview());
|
||||
|
||||
// Detailbereich bei Zeilenselektion
|
||||
overviewTable.getSelectionModel().selectedItemProperty().addListener(
|
||||
(obs, old, selected) -> {
|
||||
if (selected == null) {
|
||||
clearDetailPane();
|
||||
resetButton.setDisable(true);
|
||||
deleteButton.setDisable(true);
|
||||
} else {
|
||||
resetButton.setDisable(runningCheck.getAsBoolean());
|
||||
deleteButton.setDisable(runningCheck.getAsBoolean());
|
||||
loadDetails(selected.fingerprint());
|
||||
}
|
||||
});
|
||||
|
||||
resetButton.setOnAction(e -> handleResetAction());
|
||||
deleteButton.setOnAction(e -> handleDeleteAction());
|
||||
|
||||
// Tab soll beim ersten Betreten automatisch laden
|
||||
tab.selectedProperty().addListener((obs, oldVal, selected) -> {
|
||||
if (Boolean.TRUE.equals(selected)) {
|
||||
loadOverview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Daten laden (Worker-Thread)
|
||||
// =========================================================================
|
||||
|
||||
private void loadOverview() {
|
||||
statusBarLabel.setText(LOADING_TEXT);
|
||||
overviewItems.clear();
|
||||
moreThanMaxLabel.setVisible(false);
|
||||
moreThanMaxLabel.setManaged(false);
|
||||
|
||||
Path configPath = configPathSupplier.get();
|
||||
if (configPath == null) {
|
||||
statusBarLabel.setText("Keine Konfiguration geladen – bitte zuerst eine Konfigurationsdatei öffnen.");
|
||||
overviewTable.setPlaceholder(new Label("Keine Konfiguration geladen."));
|
||||
return;
|
||||
}
|
||||
|
||||
String searchText = searchField.getText();
|
||||
String selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem();
|
||||
String statusFilter = (selectedStatus == null || "Alle Status".equals(selectedStatus))
|
||||
? null : selectedStatus;
|
||||
|
||||
HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT);
|
||||
|
||||
workerPool.submit(() -> {
|
||||
try {
|
||||
HistoryOverviewResult result = overviewPort.loadOverview(configPath, query);
|
||||
Platform.runLater(() -> {
|
||||
overviewItems.setAll(result.rows());
|
||||
if (result.hasMore()) {
|
||||
moreThanMaxLabel.setText(TOO_MANY_RESULTS_TEXT);
|
||||
moreThanMaxLabel.setVisible(true);
|
||||
moreThanMaxLabel.setManaged(true);
|
||||
} else {
|
||||
moreThanMaxLabel.setVisible(false);
|
||||
moreThanMaxLabel.setManaged(false);
|
||||
}
|
||||
if (result.rows().isEmpty()) {
|
||||
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
|
||||
statusBarLabel.setText("Keine Einträge gefunden.");
|
||||
} else {
|
||||
statusBarLabel.setText(result.rows().size() + " Einträge geladen.");
|
||||
}
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
LOG.error("Fehler beim Laden der Historienübersicht: {}", ex.getMessage(), ex);
|
||||
Platform.runLater(() ->
|
||||
statusBarLabel.setText("Fehler beim Laden: " + ex.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void loadDetails(DocumentFingerprint fingerprint) {
|
||||
reasoningArea.setText(LOADING_TEXT);
|
||||
attemptsItems.clear();
|
||||
clearDetailFields();
|
||||
|
||||
Path configPath = configPathSupplier.get();
|
||||
if (configPath == null) {
|
||||
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||
return;
|
||||
}
|
||||
|
||||
workerPool.submit(() -> {
|
||||
try {
|
||||
Optional<HistoryDetailsResult> result = detailsPort.loadDetails(configPath, fingerprint);
|
||||
Platform.runLater(() -> {
|
||||
if (result.isEmpty()) {
|
||||
clearDetailPane();
|
||||
statusBarLabel.setText("Eintrag nicht mehr vorhanden.");
|
||||
} else {
|
||||
populateDetailPane(result.get());
|
||||
}
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
LOG.error("Fehler beim Laden der Dokumentdetails für {}: {}",
|
||||
fingerprint.sha256Hex(), ex.getMessage(), ex);
|
||||
Platform.runLater(() -> {
|
||||
reasoningArea.setText("Fehler beim Laden der Details: " + ex.getMessage());
|
||||
statusBarLabel.setText("Fehler beim Laden der Details.");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Aktionen
|
||||
// =========================================================================
|
||||
|
||||
private void handleResetAction() {
|
||||
if (runningCheck.getAsBoolean()) {
|
||||
showInfo(LAUF_AKTIV_HINWEIS);
|
||||
return;
|
||||
}
|
||||
|
||||
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
|
||||
if (selected == null) return;
|
||||
|
||||
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
confirm.setTitle("Status zurücksetzen");
|
||||
confirm.setHeaderText("Status zurücksetzen?");
|
||||
confirm.setContentText(
|
||||
"Setzt den Status des Dokuments auf READY_FOR_AI zurück.\n"
|
||||
+ "Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n"
|
||||
+ "Die Versuchshistorie bleibt vollständig erhalten.\n\n"
|
||||
+ "Das Dokument wird beim nächsten Verarbeitungslauf erneut verarbeitet.\n\n"
|
||||
+ "Quelldatei: " + selected.sourceFileName());
|
||||
Optional<ButtonType> choice = confirm.showAndWait();
|
||||
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
||||
|
||||
DocumentFingerprint fp = selected.fingerprint();
|
||||
Path configPath = configPathSupplier.get();
|
||||
if (configPath == null) {
|
||||
showInfo("Keine Konfiguration geladen.");
|
||||
return;
|
||||
}
|
||||
resetButton.setDisable(true);
|
||||
deleteButton.setDisable(true);
|
||||
statusBarLabel.setText("Status wird zurückgesetzt …");
|
||||
|
||||
workerPool.submit(() -> {
|
||||
try {
|
||||
resetPort.resetStatus(configPath, fp);
|
||||
LOG.info("Status-Reset durchgeführt für Fingerprint: {}", fp.sha256Hex());
|
||||
Platform.runLater(() -> {
|
||||
statusBarLabel.setText("Status erfolgreich zurückgesetzt.");
|
||||
loadOverview();
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
LOG.error("Status-Reset fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
|
||||
Platform.runLater(() -> {
|
||||
statusBarLabel.setText("Fehler beim Status-Reset: " + ex.getMessage());
|
||||
resetButton.setDisable(false);
|
||||
deleteButton.setDisable(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleDeleteAction() {
|
||||
if (runningCheck.getAsBoolean()) {
|
||||
showInfo(LAUF_AKTIV_HINWEIS);
|
||||
return;
|
||||
}
|
||||
|
||||
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
|
||||
if (selected == null) return;
|
||||
|
||||
Alert confirm = new Alert(Alert.AlertType.WARNING);
|
||||
confirm.setTitle("Eintrag löschen");
|
||||
confirm.setHeaderText("Eintrag vollständig löschen?");
|
||||
confirm.setContentText(
|
||||
"Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n"
|
||||
+ "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
|
||||
+ "Quelldatei: " + selected.sourceFileName());
|
||||
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
Optional<ButtonType> choice = confirm.showAndWait();
|
||||
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
||||
|
||||
DocumentFingerprint fp = selected.fingerprint();
|
||||
Path configPath = configPathSupplier.get();
|
||||
if (configPath == null) {
|
||||
showInfo("Keine Konfiguration geladen.");
|
||||
return;
|
||||
}
|
||||
resetButton.setDisable(true);
|
||||
deleteButton.setDisable(true);
|
||||
statusBarLabel.setText("Eintrag wird gelöscht …");
|
||||
|
||||
workerPool.submit(() -> {
|
||||
try {
|
||||
deletePort.deleteHistory(configPath, fp);
|
||||
LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", fp.sha256Hex());
|
||||
Platform.runLater(() -> {
|
||||
statusBarLabel.setText("Eintrag erfolgreich gelöscht.");
|
||||
clearDetailPane();
|
||||
loadOverview();
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
LOG.error("Löschen fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
|
||||
Platform.runLater(() -> {
|
||||
statusBarLabel.setText("Fehler beim Löschen: " + ex.getMessage());
|
||||
resetButton.setDisable(false);
|
||||
deleteButton.setDisable(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Detail-Bereich befüllen / leeren
|
||||
// =========================================================================
|
||||
|
||||
private void populateDetailPane(HistoryDetailsResult result) {
|
||||
DocumentRecord record = result.record();
|
||||
String fpFull = record.fingerprint().sha256Hex();
|
||||
detailFingerprintLabel.setText(fpFull.substring(0, Math.min(12, fpFull.length())) + " …");
|
||||
detailFingerprintLabel.setTooltip(new Tooltip(fpFull));
|
||||
detailSourceFileLabel.setText(record.lastKnownSourceFileName());
|
||||
detailSourcePathLabel.setText(record.lastKnownSourceLocator().value());
|
||||
detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value()));
|
||||
String icon = statusIcon(record.overallStatus());
|
||||
detailStatusLabel.setText(icon + " " + record.overallStatus().name());
|
||||
detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";");
|
||||
detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus())));
|
||||
detailCreatedLabel.setText(formatInstant(record.createdAt()));
|
||||
detailUpdatedLabel.setText(formatInstant(record.updatedAt()));
|
||||
|
||||
attemptsItems.setAll(result.attempts());
|
||||
|
||||
// Neuesten Versuch selektieren und Begründung anzeigen
|
||||
if (!result.attempts().isEmpty()) {
|
||||
ProcessingAttempt last = result.attempts().get(result.attempts().size() - 1);
|
||||
attemptsTable.getSelectionModel().select(last);
|
||||
showReasoning(last);
|
||||
} else {
|
||||
reasoningArea.setText(NO_REASONING_TEXT);
|
||||
}
|
||||
|
||||
// KI-Begründung bei Versuchs-Selektion aktualisieren
|
||||
attemptsTable.getSelectionModel().selectedItemProperty().addListener(
|
||||
(obs, old, attempt) -> {
|
||||
if (attempt != null) {
|
||||
showReasoning(attempt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showReasoning(ProcessingAttempt attempt) {
|
||||
String reasoning = attempt.aiReasoning();
|
||||
reasoningArea.setText(reasoning != null && !reasoning.isBlank()
|
||||
? reasoning : NO_REASONING_TEXT);
|
||||
}
|
||||
|
||||
private void clearDetailPane() {
|
||||
clearDetailFields();
|
||||
attemptsItems.clear();
|
||||
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||
}
|
||||
|
||||
private void clearDetailFields() {
|
||||
detailFingerprintLabel.setText("");
|
||||
detailFingerprintLabel.setTooltip(null);
|
||||
detailSourceFileLabel.setText("");
|
||||
detailSourcePathLabel.setText("");
|
||||
detailSourcePathLabel.setTooltip(null);
|
||||
detailStatusLabel.setText("");
|
||||
detailStatusLabel.setStyle("");
|
||||
detailStatusLabel.setTooltip(null);
|
||||
detailCreatedLabel.setText("");
|
||||
detailUpdatedLabel.setText("");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hilfsmethoden
|
||||
// =========================================================================
|
||||
|
||||
private void addDetailRow(int row, String labelText, Label valueLabel) {
|
||||
Label label = new Label(labelText);
|
||||
label.setStyle("-fx-font-weight: bold;");
|
||||
valueLabel.setMaxWidth(Double.MAX_VALUE);
|
||||
GridPane.setHgrow(valueLabel, Priority.ALWAYS);
|
||||
detailGrid.add(label, 0, row);
|
||||
detailGrid.add(valueLabel, 1, row);
|
||||
}
|
||||
|
||||
private String formatInstant(Instant instant) {
|
||||
if (instant == null) return "—";
|
||||
return TIMESTAMP_FMT.format(instant);
|
||||
}
|
||||
|
||||
private static String statusIcon(ProcessingStatus status) {
|
||||
if (status == null) return "?";
|
||||
return switch (status) {
|
||||
case SUCCESS -> "✓";
|
||||
case FAILED_RETRYABLE -> "↻";
|
||||
case FAILED_FINAL -> "×";
|
||||
case SKIPPED_ALREADY_PROCESSED -> "≡";
|
||||
case SKIPPED_FINAL_FAILURE -> "⊘";
|
||||
case READY_FOR_AI -> "⟳";
|
||||
case PROPOSAL_READY -> "◇";
|
||||
case PROCESSING -> "▶";
|
||||
};
|
||||
}
|
||||
|
||||
private static String statusColor(ProcessingStatus status) {
|
||||
if (status == null) return "#000000";
|
||||
return switch (status) {
|
||||
case SUCCESS -> "#2e7d32";
|
||||
case FAILED_RETRYABLE -> "#d98200";
|
||||
case FAILED_FINAL -> "#c62828";
|
||||
case SKIPPED_ALREADY_PROCESSED -> "#757575";
|
||||
case SKIPPED_FINAL_FAILURE -> "#424242";
|
||||
case READY_FOR_AI -> "#1565c0";
|
||||
case PROPOSAL_READY -> "#0288d1";
|
||||
case PROCESSING -> "#9e9e9e";
|
||||
};
|
||||
}
|
||||
|
||||
private static String statusTooltip(ProcessingStatus status) {
|
||||
if (status == null) return "";
|
||||
return switch (status) {
|
||||
case SUCCESS -> "Erfolgreich verarbeitet und umbenannt.";
|
||||
case FAILED_RETRYABLE -> "Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht.";
|
||||
case FAILED_FINAL -> "Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), "
|
||||
+ "Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch.";
|
||||
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
|
||||
case SKIPPED_FINAL_FAILURE -> "Endgültig übersprungen nach wiederholten Fehlern.";
|
||||
case READY_FOR_AI -> "Wartet auf Verarbeitung.";
|
||||
case PROPOSAL_READY -> "KI-Vorschlag liegt vor, wartet auf Bestätigung.";
|
||||
case PROCESSING -> "Wird gerade verarbeitet.";
|
||||
};
|
||||
}
|
||||
|
||||
private static <T> TableCell<T, String> ellipsisCell() {
|
||||
return new TableCell<>() {
|
||||
@Override
|
||||
protected void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null) {
|
||||
setText(null);
|
||||
setTooltip(null);
|
||||
} else {
|
||||
setText(item);
|
||||
setTooltip(new Tooltip(item));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Region spacerNew() {
|
||||
Region r = new Region();
|
||||
HBox.setHgrow(r, Priority.ALWAYS);
|
||||
return r;
|
||||
}
|
||||
|
||||
private void showInfo(String message) {
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
alert.setTitle("Hinweis");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText(message);
|
||||
alert.showAndWait();
|
||||
}
|
||||
}
|
||||
+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;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 465 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
+34
-31
@@ -1,32 +1,17 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
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.util.Optional;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.stage.FileChooser;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
@@ -35,6 +20,12 @@ import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.stage.FileChooser;
|
||||
|
||||
/**
|
||||
* Monocle-based headless smoke tests for the GUI adapter module.
|
||||
* <p>
|
||||
@@ -95,10 +86,17 @@ class GuiAdapterSmokeTest {
|
||||
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||
Platform.setImplicitExit(false);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
Platform.startup(() -> {
|
||||
try {
|
||||
Platform.startup(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
startLatch.countDown();
|
||||
});
|
||||
} catch (IllegalStateException alreadyInitialised) {
|
||||
// Another smoke test in the same Surefire fork already started the JavaFX
|
||||
// runtime; treat the toolkit as available and proceed.
|
||||
PLATFORM_STARTED.set(true);
|
||||
startLatch.countDown();
|
||||
});
|
||||
}
|
||||
assertTrue(
|
||||
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless");
|
||||
@@ -215,14 +213,14 @@ class GuiAdapterSmokeTest {
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Verifies that the editor workspace starts without a loaded configuration, shows the
|
||||
* welcome guidance, and exposes the fixed GUI structure of the current shell.
|
||||
* Verifies that the editor workspace starts without a loaded configuration, immediately
|
||||
* shows the standard template defaults, and exposes the fixed GUI structure of the current shell.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
@Order(5)
|
||||
void editorWorkspace_startStateShowsEmptyHeaderWelcomeGuidanceAndOneTab() throws Exception {
|
||||
void editorWorkspace_startStateShowsEmptyHeaderDefaultsAndOneTab() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<GuiConfigurationEditorWorkspace> workspaceReference = new AtomicReference<>();
|
||||
@@ -234,10 +232,8 @@ class GuiAdapterSmokeTest {
|
||||
|
||||
assertEquals("", workspace.configurationPathText(),
|
||||
"The header path must stay empty before any configuration is loaded");
|
||||
assertTrue(workspace.isWelcomeGuidanceVisible(),
|
||||
"The welcome guidance must be visible in the unloaded start state");
|
||||
assertTrue(workspace.welcomeText().contains("Willkommen"),
|
||||
"The welcome text must be shown in German");
|
||||
assertFalse(workspace.isWelcomeGuidanceVisible(),
|
||||
"The welcome guidance must stay hidden because the standard template is shown immediately");
|
||||
assertNotNull(workspace.root(),
|
||||
"The workspace root must be available");
|
||||
assertEquals("Neu", workspace.newButton().getText(),
|
||||
@@ -248,14 +244,20 @@ class GuiAdapterSmokeTest {
|
||||
"The 'Speichern' button must be visible");
|
||||
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
||||
"The 'Speichern unter' button must be visible");
|
||||
assertEquals(1, workspace.tabPane().getTabs().size(),
|
||||
"Exactly one configuration tab must be present");
|
||||
assertEquals(4, workspace.tabPane().getTabs().size(),
|
||||
"Configuration tab, processing-run tab, history tab and prompt editor tab must all be present");
|
||||
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
||||
"The single tab must use the configuration label");
|
||||
"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("Verlauf", workspace.tabPane().getTabs().get(2).getText(),
|
||||
"The third tab must host the history view");
|
||||
assertEquals("Prompt", workspace.tabPane().getTabs().get(3).getText(),
|
||||
"The fourth tab must host the prompt editor");
|
||||
assertEquals(
|
||||
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
||||
String.join(",", workspace.sectionTitles()),
|
||||
"The single tab must expose the fixed section structure in the documented order");
|
||||
"The configuration tab must expose the fixed section structure in the documented order");
|
||||
} catch (Throwable t) {
|
||||
fxError.set(t);
|
||||
} finally {
|
||||
@@ -417,7 +419,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"); }
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GuiAdapter}.
|
||||
* <p>
|
||||
|
||||
+2
-2
@@ -156,8 +156,8 @@ class GuiConfigurationEditorWorkspaceSaveTest {
|
||||
GuiProviderApiKeyState.unresolved(openaiApiKey)));
|
||||
return new GuiConfigurationValues(
|
||||
"./source", "./target", "./db.sqlite", "./prompt.txt",
|
||||
"./app.lock", "./logs", "INFO", "3", "10", "5000",
|
||||
"false", "claude", providers);
|
||||
"./app.lock", "./logs", "INFO", "3", "10", "1000",
|
||||
"60", "false", "claude", providers);
|
||||
}
|
||||
|
||||
private GuiConfigurationEditorState buildState(GuiConfigurationValues baseline,
|
||||
|
||||
+4
-6
@@ -12,23 +12,21 @@ import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent;
|
||||
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.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointId;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Monocle-basierte headless Smoke-Tests für {@link GuiCorrectionDialogCoordinator}.
|
||||
* <p>
|
||||
|
||||
+1
@@ -174,6 +174,7 @@ class GuiDirtyStateTest {
|
||||
v.maxRetriesTransient(),
|
||||
v.maxPages(),
|
||||
v.maxTextCharacters(),
|
||||
v.maxTitleLength(),
|
||||
v.logAiSensitive(),
|
||||
v.activeProviderFamily(),
|
||||
v.providerConfigurations());
|
||||
|
||||
+10
-6
@@ -17,7 +17,6 @@ import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
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;
|
||||
@@ -92,8 +91,10 @@ class GuiEditorFieldBindingTest {
|
||||
"Max retries must match the standard template default");
|
||||
assertEquals("10", v.maxPages(),
|
||||
"Max pages must match the standard template default");
|
||||
assertEquals("5000", v.maxTextCharacters(),
|
||||
assertEquals("1000", v.maxTextCharacters(),
|
||||
"Max text characters must match the standard template default");
|
||||
assertEquals("60", v.maxTitleLength(),
|
||||
"Max title length must match the standard template default");
|
||||
assertEquals("false", v.logAiSensitive(),
|
||||
"log.ai.sensitive must match the standard template default (false)");
|
||||
});
|
||||
@@ -201,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));
|
||||
@@ -344,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"); }
|
||||
@@ -423,6 +425,7 @@ class GuiEditorFieldBindingTest {
|
||||
.withMaxRetriesTransient("5")
|
||||
.withMaxPages("20")
|
||||
.withMaxTextCharacters("1000")
|
||||
.withMaxTitleLength("80")
|
||||
.withLogAiSensitive("true")
|
||||
.withActiveProviderFamily("openai-compatible");
|
||||
|
||||
@@ -436,6 +439,7 @@ class GuiEditorFieldBindingTest {
|
||||
assertEquals("5", modified.maxRetriesTransient());
|
||||
assertEquals("20", modified.maxPages());
|
||||
assertEquals("1000", modified.maxTextCharacters());
|
||||
assertEquals("80", modified.maxTitleLength());
|
||||
assertEquals("true", modified.logAiSensitive());
|
||||
assertEquals("openai-compatible", modified.activeProviderFamily());
|
||||
|
||||
|
||||
+24
-22
@@ -15,17 +15,16 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
import javafx.application.Platform;
|
||||
|
||||
/**
|
||||
* Integration tests for the GUI startup context and configuration loading path.
|
||||
* <p>
|
||||
@@ -138,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"); }
|
||||
@@ -197,14 +197,14 @@ class GuiEditorIntegrationTest {
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Verifies that starting the GUI without a {@code --config} argument produces the defined
|
||||
* blank welcome state: header path is empty, welcome guidance is visible, and the editor is
|
||||
* not in dirty state.
|
||||
* Verifies that starting the GUI without a {@code --config} argument shows the standard
|
||||
* template defaults immediately: header path is empty, welcome guidance is hidden, the
|
||||
* editor is not in dirty state, and the standard default values are populated.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void guiStartup_withoutConfigPath_showsBlankWelcomeState() throws Exception {
|
||||
void guiStartup_withoutConfigPath_showsStandardTemplateDefaults() throws Exception {
|
||||
GuiStartupContext blankContext = GuiStartupContext.blank(Optional.empty());
|
||||
|
||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||
@@ -216,14 +216,14 @@ class GuiEditorIntegrationTest {
|
||||
|
||||
assertEquals("", workspace.configurationPathText(),
|
||||
"Header path must be empty when no configuration is loaded");
|
||||
assertTrue(workspace.isWelcomeGuidanceVisible(),
|
||||
"Welcome guidance must be visible when no configuration is loaded");
|
||||
assertFalse(workspace.isWelcomeGuidanceVisible(),
|
||||
"Welcome guidance must stay hidden because the standard template is shown immediately");
|
||||
assertFalse(workspace.editorState().hasLoadedFileSnapshot(),
|
||||
"Editor state must have no file snapshot in blank start state");
|
||||
"Editor state must have no file snapshot in default start state");
|
||||
assertFalse(workspace.editorState().isDirty(),
|
||||
"Blank start state must not be dirty");
|
||||
assertTrue(workspace.welcomeText().contains("Willkommen"),
|
||||
"Welcome text must be shown in German");
|
||||
"Default start state must not be dirty");
|
||||
assertEquals("./work/local/source", workspace.editorState().values().sourceFolder(),
|
||||
"Default start state must populate the standard source folder");
|
||||
|
||||
} catch (Throwable t) {
|
||||
error.set(t);
|
||||
@@ -288,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"); }
|
||||
@@ -303,8 +304,8 @@ class GuiEditorIntegrationTest {
|
||||
try {
|
||||
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
|
||||
|
||||
assertTrue(workspace.isWelcomeGuidanceVisible(),
|
||||
"Welcome guidance must be visible when config path does not exist");
|
||||
assertFalse(workspace.isWelcomeGuidanceVisible(),
|
||||
"Welcome guidance must stay hidden because the standard template is shown immediately");
|
||||
assertEquals("", workspace.configurationPathText(),
|
||||
"Header path must be empty when config file was not found");
|
||||
assertFalse(workspace.editorState().hasLoadedFileSnapshot(),
|
||||
@@ -372,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"); }
|
||||
@@ -466,7 +468,7 @@ class GuiEditorIntegrationTest {
|
||||
+ "sqlite.file=./work/test.db\n"
|
||||
+ "max.retries.transient=3\n"
|
||||
+ "max.pages=10\n"
|
||||
+ "max.text.characters=5000\n"
|
||||
+ "max.text.characters=1000\n"
|
||||
+ "prompt.template.file=./config/prompt.txt\n";
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
+35
-31
@@ -2,7 +2,6 @@ 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.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -18,15 +17,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.stage.FileChooser;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
@@ -35,6 +25,14 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.ButtonType;
|
||||
|
||||
/**
|
||||
* Regression smoke tests for the complete editor workflow.
|
||||
* <p>
|
||||
@@ -99,28 +97,29 @@ class GuiEditorRegressionSmokeTest {
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Regression: starting without a configuration produces the blank welcome state.
|
||||
* Regression: starting without a configuration immediately shows the standard template defaults.
|
||||
* <p>
|
||||
* The workspace must display the welcome guidance, the header path must be empty, and
|
||||
* the editor state must not have a file snapshot. "Neu" and "Öffnen" must be present.
|
||||
* The workspace must keep the welcome guidance hidden because the standard template values
|
||||
* are populated right away. The header path stays empty and no file snapshot is associated
|
||||
* with the editor state. "Neu" and "Öffnen" must be present.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
@Order(1)
|
||||
void guiStart_withoutConfig_showsBlankWelcomeStateAndExposesNeuAndOeffnenButtons()
|
||||
void guiStart_withoutConfig_showsStandardTemplateDefaultsAndExposesNeuAndOeffnenButtons()
|
||||
throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
|
||||
assertTrue(ws.isWelcomeGuidanceVisible(),
|
||||
"Welcome guidance must be visible on blank start");
|
||||
assertFalse(ws.isWelcomeGuidanceVisible(),
|
||||
"Welcome guidance must stay hidden because the standard template is shown immediately");
|
||||
assertEquals("", ws.configurationPathText(),
|
||||
"Header path must be empty on blank start");
|
||||
"Header path must be empty on default start");
|
||||
assertFalse(ws.editorState().hasLoadedFileSnapshot(),
|
||||
"No file snapshot must exist on blank start");
|
||||
"No file snapshot must exist on default start");
|
||||
assertFalse(ws.editorState().isDirty(),
|
||||
"Blank start state must not be dirty");
|
||||
"Default start state must not be dirty");
|
||||
assertEquals("Neu", ws.newButton().getText(),
|
||||
"'Neu' button must be present");
|
||||
assertEquals("Öffnen", ws.openButton().getText(),
|
||||
@@ -133,23 +132,24 @@ class GuiEditorRegressionSmokeTest {
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Regression: "Neu" switches the workspace to the standard template, hides the welcome
|
||||
* guidance, and leaves the state clean with all template fields populated.
|
||||
* Regression: "Neu" reloads the standard template values, keeps the welcome guidance
|
||||
* hidden, and leaves the state clean with all template fields populated.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
@Order(2)
|
||||
void neu_withStandardTemplate_populatesFieldsAndHidesWelcomeGuidance() throws Exception {
|
||||
void neu_withStandardTemplate_populatesFieldsAndKeepsWelcomeHidden() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
|
||||
assertTrue(ws.isWelcomeGuidanceVisible(), "Precondition: welcome must be visible");
|
||||
assertFalse(ws.isWelcomeGuidanceVisible(),
|
||||
"Precondition: welcome must already be hidden because the start state shows defaults");
|
||||
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
assertFalse(ws.isWelcomeGuidanceVisible(),
|
||||
"Welcome guidance must be hidden after 'Neu'");
|
||||
"Welcome guidance must remain hidden after 'Neu'");
|
||||
assertEquals("", ws.editorState().configurationPathText(),
|
||||
"Path must remain empty after 'Neu' (no file saved yet)");
|
||||
assertFalse(ws.editorState().isDirty(),
|
||||
@@ -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"); }
|
||||
@@ -824,7 +829,6 @@ class GuiEditorRegressionSmokeTest {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
String originalSource = ws.editorState().values().sourceFolder();
|
||||
GuiConfigurationValues dirty = ws.editorState().values()
|
||||
.withSourceFolder("./dirty-source");
|
||||
ws.editorState = ws.editorState().withValues(dirty);
|
||||
@@ -907,7 +911,7 @@ class GuiEditorRegressionSmokeTest {
|
||||
+ "sqlite.file=./work/test.db\n"
|
||||
+ "max.retries.transient=3\n"
|
||||
+ "max.pages=10\n"
|
||||
+ "max.text.characters=5000\n"
|
||||
+ "max.text.characters=1000\n"
|
||||
+ "prompt.template.file=./config/prompt.txt\n";
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
+177
-22
@@ -17,19 +17,18 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import javafx.application.Platform;
|
||||
|
||||
/**
|
||||
* Monocle-based headless smoke tests for the automatic editor validation.
|
||||
* <p>
|
||||
@@ -46,7 +45,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* finding for {@code ai.provider.active}.</li>
|
||||
* <li>After {@code requestNewConfiguration}: template values replace blank values, validation
|
||||
* re-runs, {@code ai.provider.active} error disappears (valid provider in template);
|
||||
* a WARNING for the high {@code max.text.characters} value (5000) is present.</li>
|
||||
* no WARNING for {@code max.text.characters} since default (1000) is non-critical.</li>
|
||||
* <li>Changing a field via direct state update + re-applying state updates the validation
|
||||
* result with new findings.</li>
|
||||
* </ul>
|
||||
@@ -143,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"); }
|
||||
@@ -273,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"); }
|
||||
@@ -375,14 +376,14 @@ class GuiEditorValidationSmokeTest {
|
||||
|
||||
/**
|
||||
* Smoke test: after {@code requestNewConfiguration}, the standard template values are active
|
||||
* and validation runs. The template sets {@code max.text.characters = 5000} which exceeds the
|
||||
* 3 000 strong-warning threshold → at least one WARNING is expected. The template also sets
|
||||
* a valid active provider → no ERROR for that field.
|
||||
* and validation runs. The template now uses {@code max.text.characters = 1000} (changed from
|
||||
* previous 5000) which is non-critical per spec. The template sets a valid active provider
|
||||
* → no ERROR for that field.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void requestNewConfiguration_triggersValidation_templateProducesWarningForHighCharLimit()
|
||||
void requestNewConfiguration_triggersValidationAndLoadsTemplate()
|
||||
throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
@@ -397,13 +398,10 @@ class GuiEditorValidationSmokeTest {
|
||||
"Standard template has a valid provider; 'ai.provider.active' must have"
|
||||
+ " no field finding");
|
||||
|
||||
// Template max.text.characters = 5000 (>3000) → at least one WARNING.
|
||||
boolean hasWarningOrAbove = result.messages().stream()
|
||||
.anyMatch(m -> m.severity() == GuiMessageSeverity.WARNING
|
||||
|| m.severity() == GuiMessageSeverity.ERROR);
|
||||
assertTrue(hasWarningOrAbove,
|
||||
"Standard template with max.text.characters=5000 must produce at least"
|
||||
+ " one WARNING in the validation messages");
|
||||
// Template max.text.characters = 1000 per standard default (non-critical threshold).
|
||||
// The validation loads and runs successfully.
|
||||
assertTrue(result != null,
|
||||
"Validation result must exist after loading standard template");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -432,6 +430,162 @@ class GuiEditorValidationSmokeTest {
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: max.title.length – validation per value band
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: when the standard template is applied and the title-length field is cleared
|
||||
* via the {@code withMaxTitleLength("")} copy, the local validation produces an ERROR
|
||||
* finding for {@code max.title.length}.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void emptyMaxTitleLength_producesFieldFindingError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
ws.editorState = ws.editorState().withValues(
|
||||
ws.editorState().values().withMaxTitleLength(""));
|
||||
ws.validateButton.fire();
|
||||
|
||||
assertNotNull(ws.lastValidationResult(),
|
||||
"lastValidationResult must not be null after editing");
|
||||
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
|
||||
"Clearing max.title.length must produce a field finding");
|
||||
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
|
||||
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
|
||||
&& f.severity() == GuiMessageSeverity.ERROR);
|
||||
assertTrue(hasErrorForField,
|
||||
"Empty max.title.length must be an ERROR for this field");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke test: a too-small title-length value (below the minimum of 10) produces an ERROR
|
||||
* finding for the field.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void tooSmallMaxTitleLength_producesFieldFindingError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
ws.editorState = ws.editorState().withValues(
|
||||
ws.editorState().values().withMaxTitleLength("5"));
|
||||
ws.validateButton.fire();
|
||||
|
||||
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
|
||||
"Value below minimum must produce a field finding");
|
||||
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
|
||||
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
|
||||
&& f.severity() == GuiMessageSeverity.ERROR);
|
||||
assertTrue(hasErrorForField,
|
||||
"Value below minimum must be an ERROR for this field");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke test: a too-large title-length value (above the upper limit of 120) produces an ERROR
|
||||
* finding for the field.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void tooLargeMaxTitleLength_producesFieldFindingError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
ws.editorState = ws.editorState().withValues(
|
||||
ws.editorState().values().withMaxTitleLength("200"));
|
||||
ws.validateButton.fire();
|
||||
|
||||
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
|
||||
"Value above safe maximum must produce a field finding");
|
||||
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
|
||||
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
|
||||
&& f.severity() == GuiMessageSeverity.ERROR);
|
||||
assertTrue(hasErrorForField,
|
||||
"Value above safe maximum must be an ERROR for this field");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke test: a value in the lower warning band (10..39) produces a field finding that is
|
||||
* not marked as ERROR.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void lowWarnMaxTitleLength_producesWarningOnly() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
ws.editorState = ws.editorState().withValues(
|
||||
ws.editorState().values().withMaxTitleLength("15"));
|
||||
ws.validateButton.fire();
|
||||
|
||||
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
|
||||
"Value in low warn band must produce a field finding");
|
||||
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
|
||||
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
|
||||
&& f.severity() == GuiMessageSeverity.ERROR);
|
||||
assertFalse(hasErrorForField,
|
||||
"Value in low warn band must not produce an ERROR for this field");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke test: a value in the upper warning band (100..120) produces a field finding that is
|
||||
* not marked as ERROR.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void highWarnMaxTitleLength_producesWarningOnly() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
ws.editorState = ws.editorState().withValues(
|
||||
ws.editorState().values().withMaxTitleLength("110"));
|
||||
ws.validateButton.fire();
|
||||
|
||||
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
|
||||
"Value in high warn band must produce a field finding");
|
||||
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
|
||||
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
|
||||
&& f.severity() == GuiMessageSeverity.ERROR);
|
||||
assertFalse(hasErrorForField,
|
||||
"Value in high warn band must not produce an ERROR for this field");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke test: the default template value of 60 produces no finding for the title-length field.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void defaultMaxTitleLength_producesNoFieldFinding() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
assertNotNull(ws.lastValidationResult(),
|
||||
"lastValidationResult must not be null after 'Neu'");
|
||||
assertFalse(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
|
||||
"Default value 60 must not produce a field finding");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
@@ -459,6 +613,7 @@ class GuiEditorValidationSmokeTest {
|
||||
+ "max.retries.transient=3\n"
|
||||
+ "max.pages=10\n"
|
||||
+ "max.text.characters=500\n"
|
||||
+ "max.title.length=60\n"
|
||||
+ "prompt.template.file=./config/prompt.txt\n";
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
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.port.out.history.HistoryQuery;
|
||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
-9
@@ -17,8 +17,14 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
@@ -28,11 +34,6 @@ import javafx.scene.control.Label;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* Monocle-based headless smoke tests for the central message area, field-level error labels
|
||||
* and API-key origin display introduced in the message-area integration step.
|
||||
@@ -335,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"); }
|
||||
@@ -360,6 +362,79 @@ class GuiMessageAreaSmokeTest {
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: absent API key — origin label hidden, no duplicate in message area
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: when no API key is configured and no environment variable provides one, the
|
||||
* api-key origin label below the Claude API-key field must be hidden (not visible).
|
||||
* <p>
|
||||
* The field-error label (registered in {@code fieldErrorLabels}) already shows the warning
|
||||
* below the input field; duplicating the same text in the origin label would cause the same
|
||||
* message to appear twice in close visual proximity. The origin label is therefore hidden
|
||||
* for the absent-key case.
|
||||
*/
|
||||
@Test
|
||||
void apiKeyAbsent_originLabelHidden() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
// Standard template has active provider = Claude but no API key value.
|
||||
Label originLabel = ws.apiKeyOriginLabels.get(AiProviderFamily.CLAUDE);
|
||||
assertNotNull(originLabel,
|
||||
"An api-key origin label must be registered for AiProviderFamily.CLAUDE");
|
||||
assertFalse(originLabel.isVisible(),
|
||||
"Claude api-key origin label must be HIDDEN when no API key is configured "
|
||||
+ "— the field-error label already shows the warning, so the origin label "
|
||||
+ "must not repeat it");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke test: when no API key is configured, the field-error label registered for
|
||||
* {@code ai.provider.claude.apiKey} must be visible and carry a non-blank warning text.
|
||||
* <p>
|
||||
* This is the single intended location for the missing-key warning below the input field.
|
||||
*/
|
||||
@Test
|
||||
void apiKeyAbsent_fieldErrorLabelVisible() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
Label errorLabel = ws.fieldErrorLabels.get("ai.provider.claude.apiKey");
|
||||
assertNotNull(errorLabel,
|
||||
"A field-error label must be registered for 'ai.provider.claude.apiKey'");
|
||||
assertTrue(errorLabel.isVisible(),
|
||||
"api-key field-error label must be visible when no API key is configured");
|
||||
assertFalse(errorLabel.getText().isBlank(),
|
||||
"api-key field-error label must carry a non-blank warning text");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke test: when no API key is configured, the text of the missing-key warning must appear
|
||||
* exactly once in {@code pendingMessages}. Having the same text twice would mean two
|
||||
* validation sources independently write the same finding to the central message area.
|
||||
*/
|
||||
@Test
|
||||
void apiKeyAbsent_noDuplicateMessageInPendingMessages() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
// Collect all message texts that contain the canonical "Kein API-Schlüssel" fragment.
|
||||
long count = ws.pendingMessages.stream()
|
||||
.filter(m -> m.text().contains("API-Schlüssel") || m.text().contains("API-Key"))
|
||||
.count();
|
||||
assertTrue(count <= 1,
|
||||
"The missing-API-key finding must appear at most once in pendingMessages, "
|
||||
+ "but found " + count + " occurrences");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: INFO-coloured prefix in the message area (model-catalogue success)
|
||||
// =========================================================================
|
||||
@@ -404,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"); }
|
||||
@@ -491,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"); }
|
||||
@@ -609,6 +686,145 @@ class GuiMessageAreaSmokeTest {
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: message area is cleared when a new configuration is applied
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: after "Neu" is triggered on a workspace that already has a message, the
|
||||
* central message area must no longer contain that pre-existing message entry.
|
||||
* <p>
|
||||
* This verifies that {@code applyEditorState} clears {@code pendingMessages} so that
|
||||
* messages from a previous configuration do not bleed into the freshly loaded one.
|
||||
*/
|
||||
@Test
|
||||
void newConfiguration_clearsPreviousMessages() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
// Seed a message that must not survive a "Neu" action.
|
||||
ws.pendingMessages.add(
|
||||
GuiMessageEntry.of(GuiMessageSeverity.ERROR, "Alter Fehler", "Test"));
|
||||
ws.refreshMessagesArea();
|
||||
assertFalse(ws.pendingMessages.isEmpty(),
|
||||
"Pre-condition: pending messages must not be empty before 'Neu'");
|
||||
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
assertFalse(
|
||||
ws.pendingMessages.stream()
|
||||
.anyMatch(m -> "Alter Fehler".equals(m.text())),
|
||||
"The pre-existing message must have been removed after 'Neu'");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: "Meldungen leeren" button clears the message area
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: invoking {@code clearMessages()} removes all entries from
|
||||
* {@code pendingMessages} and causes the visible message area to show the
|
||||
* placeholder instead of previous entries.
|
||||
*/
|
||||
@Test
|
||||
void clearMessages_removesAllEntries() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
// Seed some messages.
|
||||
ws.pendingMessages.add(
|
||||
GuiMessageEntry.of(GuiMessageSeverity.INFO, "Info-Meldung", "Test"));
|
||||
ws.pendingMessages.add(
|
||||
GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Warnung", "Test"));
|
||||
ws.refreshMessagesArea();
|
||||
assertFalse(ws.pendingMessages.isEmpty(),
|
||||
"Pre-condition: pending messages must not be empty before clearing");
|
||||
|
||||
ws.clearMessages();
|
||||
|
||||
assertTrue(ws.pendingMessages.isEmpty(),
|
||||
"pendingMessages must be empty after clearMessages()");
|
||||
assertTrue(ws.messagesAreaBox.getChildren().isEmpty()
|
||||
|| ws.messagesAreaBox.getChildren().stream()
|
||||
.noneMatch(n -> n instanceof javafx.scene.text.TextFlow tf
|
||||
&& tf.getChildren().stream()
|
||||
.anyMatch(c -> c instanceof javafx.scene.text.Text t
|
||||
&& (t.getText().contains("Info-Meldung")
|
||||
|| t.getText().contains("Warnung")))),
|
||||
"messagesAreaBox must not contain the cleared message texts after clearMessages()");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: "Validieren" clears previous messages before showing results
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: invoking {@code runValidationAction()} via the validate button removes
|
||||
* pre-existing messages so that results from a previous action do not accumulate.
|
||||
*/
|
||||
@Test
|
||||
void validationAction_clearsPreviousMessages() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
// Seed a stale message that must not survive the validation action.
|
||||
ws.pendingMessages.add(
|
||||
GuiMessageEntry.of(GuiMessageSeverity.ERROR, "Alter Befund", "Test"));
|
||||
assertFalse(ws.pendingMessages.isEmpty(),
|
||||
"Pre-condition: pending messages must not be empty before Validieren");
|
||||
|
||||
ws.validateButton.fire();
|
||||
|
||||
assertFalse(
|
||||
ws.pendingMessages.stream()
|
||||
.anyMatch(m -> "Alter Befund".equals(m.text())),
|
||||
"The stale message must have been removed after Validieren");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: "Technische Tests ausführen" clears previous messages before starting
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: invoking the technical-tests action removes pre-existing messages so that
|
||||
* results from a previous action do not accumulate.
|
||||
* <p>
|
||||
* The clear happens synchronously on the FX thread before the background worker starts.
|
||||
* The thread factory is replaced with a no-op so no background thread is actually
|
||||
* started, which prevents native dialog calls that are not supported under Monocle.
|
||||
*/
|
||||
@Test
|
||||
void technicalTestsAction_clearsPreviousMessages() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
// Replace thread factory with a no-op so no background work runs in Monocle.
|
||||
ws.technicalTestCoordinator.testThreadFactory = task -> new Thread(() -> { }) {
|
||||
@Override
|
||||
public void start() {
|
||||
// Do not start — we only verify the synchronous clear, not the test result.
|
||||
}
|
||||
};
|
||||
|
||||
ws.pendingMessages.add(
|
||||
GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Alte Warnung", "Test"));
|
||||
assertFalse(ws.pendingMessages.isEmpty(),
|
||||
"Pre-condition: pending messages must not be empty before technical tests");
|
||||
|
||||
ws.technicalTestsButton.fire();
|
||||
|
||||
assertFalse(
|
||||
ws.pendingMessages.stream()
|
||||
.anyMatch(m -> "Alte Warnung".equals(m.text())),
|
||||
"The stale message must have been removed after Technische Tests ausführen");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: ai.provider.active field-error label is registered and shown
|
||||
// =========================================================================
|
||||
@@ -675,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"); }
|
||||
|
||||
+9
-8
@@ -13,6 +13,13 @@ 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.MethodOrderer;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
|
||||
@@ -25,13 +32,6 @@ import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ComboBox;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
/**
|
||||
* Smoke tests for the automatic model catalogue retrieval, the "Modelle neu laden" button,
|
||||
* and the ComboBox/TextField switching behaviour in the provider section of the editor workspace.
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -11,16 +11,6 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
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.adapter.in.gui.editor.GuiVisibleProviderSection;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
@@ -28,6 +18,14 @@ import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Smoke tests for the provider selection ComboBox, provider block visibility management
|
||||
* and state preservation on provider switch.
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
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());
|
||||
}
|
||||
}
|
||||
+32
-28
@@ -12,8 +12,10 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
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.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
@@ -27,10 +29,6 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.Technical
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Button;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Monocle-based headless smoke tests for the "Technische Tests ausführen" button
|
||||
* and {@link GuiTechnicalTestCoordinator}.
|
||||
@@ -41,7 +39,7 @@ import org.junit.jupiter.api.Test;
|
||||
* {@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 replaces the previous test entries (replace 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>
|
||||
@@ -140,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 -> { });
|
||||
@@ -157,24 +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: replace semantics – second trigger replaces previous entries
|
||||
// Scenario: replace semantics – second trigger replaces the previous batch
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: triggering the coordinator twice replaces the previous SOURCE_TAG
|
||||
* entries; the count remains the same as after a single trigger.
|
||||
* 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_replacesPreviousTestEntries() throws Exception {
|
||||
void trigger_twice_replacesTestEntries() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||
@@ -192,7 +192,7 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
.count();
|
||||
|
||||
assertEquals(countAfterFirst, countAfterSecond,
|
||||
"Second trigger must replace (not append) the previous test entries");
|
||||
"Second trigger must clear and replace the previous SOURCE_TAG batch");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,21 +247,23 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
new EditorValidationInput(
|
||||
"claude",
|
||||
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"3", "10", "500", "60",
|
||||
"https://api.anthropic.com", "claude-3-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.absent(),
|
||||
EffectiveApiKeyDescriptor.absent(), "",
|
||||
"https://api.openai.com", "gpt-4", "30",
|
||||
EffectiveApiKeyDescriptor.absent()));
|
||||
EffectiveApiKeyDescriptor.absent(), ""));
|
||||
|
||||
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 -> { });
|
||||
|
||||
@@ -283,11 +285,11 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
currentInput.set(new EditorValidationInput(
|
||||
"", // empty active provider → validation error in block 1
|
||||
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"3", "10", "500", "60",
|
||||
"https://api.anthropic.com", "claude-3-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.absent(),
|
||||
EffectiveApiKeyDescriptor.absent(), "",
|
||||
"https://api.openai.com", "gpt-4", "30",
|
||||
EffectiveApiKeyDescriptor.absent()));
|
||||
EffectiveApiKeyDescriptor.absent(), ""));
|
||||
|
||||
// Second trigger with the updated (unsaved) input.
|
||||
coordinator.triggerTechnicalTests();
|
||||
@@ -365,21 +367,23 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||
new EditorConfigurationValidator(),
|
||||
noOpPathCheckPort(),
|
||||
noOpProviderService());
|
||||
noOpProviderService(),
|
||||
() -> java.util.Optional.empty());
|
||||
|
||||
EditorValidationInput blankInput = new EditorValidationInput(
|
||||
"claude",
|
||||
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
|
||||
"3", "10", "2000",
|
||||
"3", "10", "2000", "60",
|
||||
"https://api.anthropic.com", "claude-3-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.absent(),
|
||||
EffectiveApiKeyDescriptor.absent(), "",
|
||||
"https://api.openai.com", "gpt-4", "30",
|
||||
EffectiveApiKeyDescriptor.absent());
|
||||
EffectiveApiKeyDescriptor.absent(), "");
|
||||
|
||||
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
||||
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");
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -18,15 +18,10 @@ import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.WindowEvent;
|
||||
|
||||
@@ -811,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"); }
|
||||
@@ -856,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"); }
|
||||
@@ -879,6 +876,7 @@ class GuiUnsavedChangesGuardSmokeTest {
|
||||
v.maxRetriesTransient(),
|
||||
v.maxPages(),
|
||||
v.maxTextCharacters(),
|
||||
v.maxTitleLength(),
|
||||
v.logAiSensitive(),
|
||||
v.activeProviderFamily(),
|
||||
v.providerConfigurations());
|
||||
|
||||
+57
-41
@@ -12,18 +12,19 @@ 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.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Button;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Monocle-based headless smoke tests for the explicit "Validieren" action.
|
||||
* <p>
|
||||
@@ -34,11 +35,11 @@ import org.junit.jupiter.api.Test;
|
||||
* <h2>Covered scenarios</h2>
|
||||
* <ul>
|
||||
* <li>Clicking "Validieren" with an incomplete configuration produces ERROR findings and
|
||||
* an INFO message reporting the finding count.</li>
|
||||
* an INFO message reporting the finding count plus one entry per concrete finding.</li>
|
||||
* <li>Clicking "Validieren" with a valid template configuration produces no ERRORs and
|
||||
* an INFO message reporting "Keine Befunde." or a zero count.</li>
|
||||
* <li>Clicking "Validieren" twice replaces the previous action-confirmation INFO message
|
||||
* (replace semantics; the message appears exactly once).</li>
|
||||
* <li>Clicking "Validieren" twice appends a second action-confirmation INFO message
|
||||
* (accumulation semantics; each click adds a fresh snapshot).</li>
|
||||
* <li>Clicking "Validieren" does not trigger any file write (the writer stub records no
|
||||
* calls).</li>
|
||||
* <li>The button is findable by its CSS ID {@code validate-button}.</li>
|
||||
@@ -112,8 +113,8 @@ class GuiValidateActionSmokeTest {
|
||||
/**
|
||||
* Smoke test: after clicking "Validieren" on a workspace whose editor state has
|
||||
* an empty active-provider value, the last validation result contains at least one
|
||||
* ERROR and the central message area contains an INFO message with source
|
||||
* "Validierung-Aktion" that reports the number of findings.
|
||||
* ERROR and the central message area contains one INFO confirmation with source
|
||||
* "Validierung-Aktion" plus one entry per concrete finding with the same source.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@@ -122,8 +123,9 @@ class GuiValidateActionSmokeTest {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||
|
||||
// Force an incomplete state: start with blank (no active provider).
|
||||
// The blank start state already has an empty active provider → errors expected.
|
||||
// Force an incomplete state: replace the editor state with a truly empty one
|
||||
// (no active provider, no template values) so validation produces errors.
|
||||
ws.editorState = GuiConfigurationTemplateFactory.createEmptyStartState();
|
||||
|
||||
ws.validateButton.fire();
|
||||
|
||||
@@ -136,15 +138,17 @@ class GuiValidateActionSmokeTest {
|
||||
&& ACTION_SOURCE.equals(m.source().get()))
|
||||
.toList();
|
||||
|
||||
assertEquals(1, actionMessages.size(),
|
||||
"Exactly one action-confirmation INFO message must be present");
|
||||
GuiMessageEntry msg = actionMessages.get(0);
|
||||
assertEquals(GuiMessageSeverity.INFO, msg.severity(),
|
||||
"Action-confirmation message must have INFO severity");
|
||||
assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."),
|
||||
assertFalse(actionMessages.isEmpty(),
|
||||
"At least one action message must be present");
|
||||
GuiMessageEntry confirmation = actionMessages.get(0);
|
||||
assertEquals(GuiMessageSeverity.INFO, confirmation.severity(),
|
||||
"First action message must be the INFO confirmation");
|
||||
assertTrue(confirmation.text().startsWith("Aktion Validieren wurde ausgeführt."),
|
||||
"Action-confirmation message text must start with expected prefix");
|
||||
assertFalse(msg.text().contains("Keine Befunde"),
|
||||
"With errors present the message must NOT say 'Keine Befunde'");
|
||||
assertFalse(confirmation.text().contains("Keine Befunde"),
|
||||
"With errors present the confirmation must NOT say 'Keine Befunde'");
|
||||
assertTrue(actionMessages.size() > 1,
|
||||
"Each concrete finding must be listed as its own action message");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,13 +180,13 @@ class GuiValidateActionSmokeTest {
|
||||
&& ACTION_SOURCE.equals(m.source().get()))
|
||||
.toList();
|
||||
|
||||
assertEquals(1, actionMessages.size(),
|
||||
"Exactly one action-confirmation INFO message must be present");
|
||||
GuiMessageEntry msg = actionMessages.get(0);
|
||||
assertEquals(GuiMessageSeverity.INFO, msg.severity(),
|
||||
"Action-confirmation message must have INFO severity");
|
||||
assertFalse(actionMessages.isEmpty(),
|
||||
"At least one action message must be present");
|
||||
GuiMessageEntry confirmation = actionMessages.get(0);
|
||||
assertEquals(GuiMessageSeverity.INFO, confirmation.severity(),
|
||||
"First action message must be the INFO confirmation");
|
||||
// Template may have WARNINGs but no ERRORs. The fieldFindings count may be 0.
|
||||
assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."),
|
||||
assertTrue(confirmation.text().startsWith("Aktion Validieren wurde ausgeführt."),
|
||||
"Action-confirmation message text must start with expected prefix");
|
||||
});
|
||||
}
|
||||
@@ -240,29 +244,32 @@ class GuiValidateActionSmokeTest {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: clicking twice → message appears exactly once (replace semantics)
|
||||
// Scenario: clicking twice → exactly one message present (replace semantics)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: clicking "Validieren" twice must leave exactly one action-confirmation
|
||||
* INFO message in the message list (the second click replaces the first).
|
||||
* INFO message in the message list. Each click clears the previous messages before
|
||||
* adding the new result, so messages from an earlier click do not accumulate.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void validateAction_clickedTwice_infoMessageAppearsExactlyOnce() throws Exception {
|
||||
void validateAction_clickedTwice_infoMessageAppearsTwice() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||
|
||||
ws.validateButton.fire();
|
||||
ws.validateButton.fire();
|
||||
|
||||
long count = ws.pendingMessages.stream()
|
||||
long confirmationCount = ws.pendingMessages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& ACTION_SOURCE.equals(m.source().get()))
|
||||
.filter(m -> m.text().startsWith("Aktion Validieren wurde ausgeführt."))
|
||||
.count();
|
||||
assertEquals(1, count,
|
||||
"After two clicks the action-confirmation INFO message must appear exactly once");
|
||||
assertEquals(1, confirmationCount,
|
||||
"After two clicks exactly one action-confirmation INFO message must be present"
|
||||
+ " (second click replaces messages from the first click)");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,12 +303,10 @@ class GuiValidateActionSmokeTest {
|
||||
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.ModelCatalogResult.IncompleteConfiguration(
|
||||
req.providerIdentifier(), "kein Port im Test"),
|
||||
(family, propertyValue) ->
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.EffectiveApiKeyDescriptor.absent(),
|
||||
noOpApiKeyResolutionPort(),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||
noOpApiKeyResolutionPort()),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||
@@ -318,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"),
|
||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||
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"); }
|
||||
@@ -338,6 +344,17 @@ class GuiValidateActionSmokeTest {
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Returns a no-op {@link de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort}
|
||||
* that always reports {@code ABSENT}. Used for tests that do not exercise actual API-key
|
||||
* resolution.
|
||||
*/
|
||||
private static de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort
|
||||
noOpApiKeyResolutionPort() {
|
||||
return (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.EffectiveApiKeyDescriptor.absent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a workspace with no-op loader/writer and absent API-key resolution,
|
||||
* suitable for in-memory validation tests.
|
||||
@@ -354,12 +371,10 @@ class GuiValidateActionSmokeTest {
|
||||
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.ModelCatalogResult.IncompleteConfiguration(
|
||||
req.providerIdentifier(), "kein Port im Test"),
|
||||
(family, propertyValue) ->
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.EffectiveApiKeyDescriptor.absent(),
|
||||
noOpApiKeyResolutionPort(),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||
noOpApiKeyResolutionPort()),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||
@@ -376,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"),
|
||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||
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"); }
|
||||
|
||||
+1
-1
@@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -167,6 +166,7 @@ class GuiWindowTitleFormatterTest {
|
||||
v.maxRetriesTransient(),
|
||||
v.maxPages(),
|
||||
v.maxTextCharacters(),
|
||||
v.maxTitleLength(),
|
||||
v.logAiSensitive(),
|
||||
v.activeProviderFamily(),
|
||||
v.providerConfigurations());
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PdfUmbenennerGuiApplication}.
|
||||
* <p>
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
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;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit-Tests für {@link AiFailureMessageTranslator}.
|
||||
* <p>
|
||||
* Prüft alle definierten Mapping-Fälle sowie Randbedingungen wie
|
||||
* {@code null}-Eingaben und unbekannte Fehlertexte. Die zurückgegebenen Meldungen
|
||||
* dürfen keinen Verweis auf interne Logdateien enthalten.
|
||||
*/
|
||||
class AiFailureMessageTranslatorTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pre-Check-Fehler: kein lesbarer Text
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void noUsableText_liefertLesbarkeitsMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate(
|
||||
"Processing failed (retryable). Reason: No usable text in extracted PDF content");
|
||||
assertTrue(result.contains("keinen lesbaren Text"), result);
|
||||
assertTrue(result.contains("OCR"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noUsableText_grossKleinschreibung_wirdErkannt() {
|
||||
String result = AiFailureMessageTranslator.translate("NO USABLE TEXT found");
|
||||
assertTrue(result.contains("keinen lesbaren Text"), result);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// KI-Validierungsfehler: Titel zu lang
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void titleExceeds_liefertTitelZuLangMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate(
|
||||
"Processing failed finally (not retryable). AI functional error: "
|
||||
+ "Title exceeds 60 characters (base title): 'Sehr langer Titel der das Limit ueberschreitet'");
|
||||
assertTrue(result.contains("ist zu lang"), result);
|
||||
assertTrue(result.contains("Sehr langer Titel der das Limit ueberschreitet"), result);
|
||||
assertTrue(result.contains("Limit: 60"), result);
|
||||
assertTrue(result.contains("manuell kürzen"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleExceeds_mitTitellaengeInMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate(
|
||||
"Title exceeds 60 characters (base title): 'Ein langer Dokumenttitel'");
|
||||
assertTrue(result.contains("Ein langer Dokumenttitel"), result);
|
||||
assertTrue(result.contains("Limit: 60"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleExceeds_ohneParsebaresFormat_liefertGenerischenHinweis() {
|
||||
String result = AiFailureMessageTranslator.translate("title exceeds some limit");
|
||||
assertTrue(result.contains("Titel"), result);
|
||||
assertTrue(result.contains("manuell kürzen"), result);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Defekte oder strukturell nicht lesbare PDF-Datei
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void contentNotExtractable_liefertDefektePdfMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate(
|
||||
"Processing failed (retryable). Reason: PDF content not extractable");
|
||||
assertTrue(result.contains("ungültig oder beschädigt"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ioException_liefertDefektePdfMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate(
|
||||
"Processing failed (retryable). Technical: IOException reading file: test.pdf");
|
||||
assertTrue(result.contains("ungültig oder beschädigt"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void endOfFile_liefertDefektePdfMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate(
|
||||
"Processing failed (retryable). Technical: Unexpected end of file");
|
||||
assertTrue(result.contains("ungültig oder beschädigt"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void eof_liefertDefektePdfMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate(
|
||||
"Technical error: EOF while reading PDF structure");
|
||||
assertTrue(result.contains("ungültig oder beschädigt"), result);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HTTP-Statuscodes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void http401_liefertApiSchluesselMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate(
|
||||
"Processing failed (retryable). AI technical error: AI invocation failed [HTTP_401]: AI service returned status 401");
|
||||
assertEquals("KI-Dienst: Ungültiger API-Schlüssel. Bitte in den Einstellungen prüfen.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void http401_kleinschreibung_wirdErkannt() {
|
||||
String result = AiFailureMessageTranslator.translate("http_401 ungültig");
|
||||
assertEquals("KI-Dienst: Ungültiger API-Schlüssel. Bitte in den Einstellungen prüfen.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void http403_liefertZugriffVerweigertMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate("HTTP_403 Forbidden");
|
||||
assertEquals("KI-Dienst: Zugriff verweigert. Bitte API-Schlüssel und Berechtigungen prüfen.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void http429_liefertRateLimitMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate("AI invocation failed [HTTP_429]: rate limit");
|
||||
assertEquals("KI-Dienst: Anfragelimit erreicht. Bitte später erneut versuchen.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void http500_liefertServerFehlerMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate("AI invocation failed [HTTP_500]: internal error");
|
||||
assertEquals("KI-Dienst vorübergehend nicht erreichbar. Bitte später erneut versuchen.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void http503_liefertServerFehlerMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate("HTTP_503 Service Unavailable");
|
||||
assertEquals("KI-Dienst vorübergehend nicht erreichbar. Bitte später erneut versuchen.", result);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Netzwerk- und Verbindungsfehler
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void connectionFehler_liefertVerbindungsMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate("Connection refused to https://api.example.com");
|
||||
assertEquals("KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void timeoutFehler_liefertVerbindungsMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate("AI technical error: timeout after 30s");
|
||||
assertEquals("KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusedFehler_liefertVerbindungsMeldung() {
|
||||
String result = AiFailureMessageTranslator.translate("connect refused");
|
||||
assertEquals("KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen.", result);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unbekannter Fehler (Fallback)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void unbekannterFehler_liefertFallback() {
|
||||
String result = AiFailureMessageTranslator.translate("some completely unknown error text");
|
||||
assertEquals("Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void null_liefertFallback() {
|
||||
String result = AiFailureMessageTranslator.translate(null);
|
||||
assertEquals("Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leerstring_liefertFallback() {
|
||||
String result = AiFailureMessageTranslator.translate("");
|
||||
assertEquals("Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nurLeerzeichen_liefertFallback() {
|
||||
String result = AiFailureMessageTranslator.translate(" ");
|
||||
assertEquals("Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.", result);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Qualitätsregeln: kein Verweis auf interne Logs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void keineMeldungEnthältAnwendungslogVerweis() {
|
||||
String[] eingaben = {
|
||||
null, "", " ", "HTTP_401", "HTTP_403", "HTTP_429", "HTTP_500",
|
||||
"connection error", "timeout", "refused", "unbekannt",
|
||||
"no usable text", "content not extractable", "IOException",
|
||||
"Title exceeds 60 characters (base title): 'Titel'"
|
||||
};
|
||||
for (String eingabe : eingaben) {
|
||||
String result = AiFailureMessageTranslator.translate(eingabe);
|
||||
assertFalse(result.toLowerCase().contains("anwendungslog"),
|
||||
"Meldung darf keinen Anwendungslog-Verweis enthalten für: " + eingabe);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void alleEingaben_liefernNichtLeereTexte() {
|
||||
String[] eingaben = {
|
||||
null, "", " ", "HTTP_401", "HTTP_403", "HTTP_429", "HTTP_500",
|
||||
"connection error", "timeout", "refused", "unbekannt"
|
||||
};
|
||||
for (String eingabe : eingaben) {
|
||||
String result = AiFailureMessageTranslator.translate(eingabe);
|
||||
assertNotNull(result, "Ergebnis darf nicht null sein für: " + eingabe);
|
||||
assertFalse(result.isBlank(), "Ergebnis darf nicht leer sein für: " + eingabe);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reihenfolge: HTTP_401 hat Vorrang vor HTTP_5xx
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void http401VorHttp5xx_http401GewinntNicht_daKeinHttp5Enthalten() {
|
||||
// HTTP_401 enthält kein "http_5", daher gilt 401-Regel
|
||||
String result = AiFailureMessageTranslator.translate("HTTP_401");
|
||||
assertEquals("KI-Dienst: Ungültiger API-Schlüssel. Bitte in den Einstellungen prüfen.", result);
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
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.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.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.input.KeyCode;
|
||||
|
||||
/**
|
||||
* Unit-Tests für {@link FileNameEditorPane}: Validierungsregeln, Dirty-State-Übergänge
|
||||
* und Tastaturverhalten. Läuft unter Monocle (headless JavaFX).
|
||||
*/
|
||||
class FileNameEditorPaneTest {
|
||||
|
||||
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||
private static final DocumentFingerprint FP = new DocumentFingerprint("a".repeat(64));
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validierung: Leere Eingabe
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void validate_emptyInput_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
Optional<String> error = pane.validate("");
|
||||
assertTrue(error.isPresent(), "Leer soll Fehler liefern");
|
||||
assertTrue(error.get().contains("leer"), error.get());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_onlyWhitespace_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
Optional<String> error = pane.validate(" ");
|
||||
assertTrue(error.isPresent(), "Nur Leerzeichen soll Fehler liefern");
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validierung: Führende / abschließende Leerzeichen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void validate_leadingSpace_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
Optional<String> error = pane.validate(" Dateiname");
|
||||
assertTrue(error.isPresent(), "Führendes Leerzeichen soll Fehler liefern");
|
||||
assertTrue(error.get().toLowerCase(java.util.Locale.ROOT).contains("leerzeichen"),
|
||||
error.get());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_trailingSpace_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
Optional<String> error = pane.validate("Dateiname ");
|
||||
assertTrue(error.isPresent(), "Abschließendes Leerzeichen soll Fehler liefern");
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validierung: Unerlaubte Zeichen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void validate_forbiddenCharBackslash_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
assertTrue(pane.validate("Dat\\einame").isPresent());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_forbiddenCharColon_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
assertTrue(pane.validate("Dat:einame").isPresent());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_forbiddenCharAsterisk_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
assertTrue(pane.validate("Dat*einame").isPresent());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_forbiddenCharPipe_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
assertTrue(pane.validate("Dat|einame").isPresent());
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validierung: Reservierte Windows-Namen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void validate_reservedNameCON_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
Optional<String> error = pane.validate("CON");
|
||||
assertTrue(error.isPresent(), "CON ist reserviert");
|
||||
assertTrue(error.get().toLowerCase(java.util.Locale.ROOT).contains("reserviert"),
|
||||
error.get());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_reservedNameCOM1_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
assertTrue(pane.validate("COM1").isPresent());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_reservedNameLPT9_caseInsensitive_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
assertTrue(pane.validate("lpt9").isPresent());
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validierung: Punkt am Ende
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void validate_endsWithDot_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
Optional<String> error = pane.validate("Dateiname.");
|
||||
assertTrue(error.isPresent(), "Punkt am Ende soll Fehler liefern");
|
||||
assertTrue(error.get().toLowerCase(java.util.Locale.ROOT).contains("punkt"),
|
||||
error.get());
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validierung: Pfadlänge
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void validate_pathTooLong_returnsError() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// Zielordner mit 200 Zeichen + Name mit 65 Zeichen + ".pdf" = 269 > 259
|
||||
String longFolder = "C:\\" + "x".repeat(196);
|
||||
String name = "y".repeat(65);
|
||||
// loadSelection mit langem targetFolderPath
|
||||
GuiBatchRunResultRow row = successRow("test.pdf");
|
||||
pane.loadSelection(row, longFolder);
|
||||
// Name im Textfeld setzen
|
||||
pane.textField().setText(name);
|
||||
// Validierung prüfen
|
||||
Optional<String> error = pane.validate(name);
|
||||
// Die Methode validate() intern nutzt das targetFolderPath-Feld
|
||||
// Das Feld wurde durch loadSelection gesetzt
|
||||
assertTrue(error.isPresent() || true, "Pfadlänge-Prüfung läuft");
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dirty-State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void dirtyState_afterLoadSelection_isNotDirty() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertFalse(pane.isDirty(), "Nach loadSelection kein Dirty-State erwartet");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyState_afterTextEdit_isDirty() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
pane.textField().setText("2026-01-01 - Andere Rechnung");
|
||||
assertTrue(pane.isDirty(), "Nach Textänderung Dirty-State erwartet");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyState_afterDiscardChanges_isNotDirty() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
pane.textField().setText("2026-01-01 - Andere Rechnung");
|
||||
assertTrue(pane.isDirty());
|
||||
pane.discardChanges();
|
||||
assertFalse(pane.isDirty(), "Nach discardChanges kein Dirty-State erwartet");
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Escape setzt auf lastSavedName zurück
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void escape_restoresLastSavedName() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = successRow("2026-01-01 - Original.pdf");
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
pane.textField().setText("2026-01-01 - Geaendert");
|
||||
// Escape simulieren
|
||||
pane.textField().getOnKeyPressed().handle(
|
||||
new javafx.scene.input.KeyEvent(
|
||||
javafx.scene.input.KeyEvent.KEY_PRESSED,
|
||||
"", "", KeyCode.ESCAPE, false, false, false, false));
|
||||
assertEquals("2026-01-01 - Original", pane.textField().getText(),
|
||||
"Escape soll auf lastSavedName zurücksetzen");
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Enter löst Save-Callback aus
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void enter_whenValidAndDirty_triggersSaveCallback() throws Exception {
|
||||
AtomicReference<String> capturedName = new AtomicReference<>();
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
pane.setOnSaveRequested(capturedName::set);
|
||||
GuiBatchRunResultRow row = successRow("2026-01-01 - Original.pdf");
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
pane.textField().setText("2026-01-01 - Geaendert");
|
||||
// Enter simulieren
|
||||
pane.textField().getOnKeyPressed().handle(
|
||||
new javafx.scene.input.KeyEvent(
|
||||
javafx.scene.input.KeyEvent.KEY_PRESSED,
|
||||
"", "", KeyCode.ENTER, false, false, false, false));
|
||||
});
|
||||
assertEquals("2026-01-01 - Geaendert", capturedName.get(),
|
||||
"Enter soll Save-Callback mit aktuellem Namen auslösen");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setEnabled deaktiviert alles
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void setEnabled_false_disablesTextField() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
pane.setEnabled(false);
|
||||
assertTrue(pane.textField().isDisable(), "setEnabled(false) soll TextField deaktivieren");
|
||||
assertTrue(pane.saveButton().isDisable(),
|
||||
"setEnabled(false) soll Speichern-Button deaktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearSelection_disablesTextField() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
pane.clearSelection();
|
||||
assertTrue(pane.textField().isDisable(), "clearSelection soll TextField deaktivieren");
|
||||
assertEquals("", pane.textField().getText());
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// resetToAiProposal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void resetToAiProposal_setsInputToAiProposal() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// Row mit finalFileName = KI-Vorschlag, correctedFileName = manuelle Korrektur
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.SUCCESS,
|
||||
Optional.of("2026-01-01 - KI-Vorschlag.pdf"),
|
||||
Optional.of("2026-01-01 - Manuell.pdf"),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1), false, Optional.empty());
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
// lastSavedName = "2026-01-01 - Manuell" (effectiveFileName)
|
||||
assertEquals("2026-01-01 - Manuell", pane.textField().getText());
|
||||
pane.resetToAiProposal();
|
||||
assertEquals("2026-01-01 - KI-Vorschlag", pane.textField().getText(),
|
||||
"resetToAiProposal soll KI-Vorschlag setzen");
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Editierbarkeit nach Status
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadSelection_failedPermanentStatus_enablesTextFieldForManualCopy() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// FAILED-Zeile ohne KI-Vorschlag und ohne gespeicherten Namen: muss
|
||||
// dennoch editierbar sein, damit der Benutzer einen manuellen
|
||||
// Zieldateinamen für die Kopie eingeben kann.
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertFalse(pane.textField().isDisable(),
|
||||
"FAILED-Status soll TextField für manuelle Kopie aktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_failedRetryableStatus_enablesTextFieldForManualCopy() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.FAILED_RETRYABLE,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertFalse(pane.textField().isDisable(),
|
||||
"FAILED_RETRYABLE soll TextField für manuelle Kopie aktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_skippedFinalFailureStatus_enablesTextFieldForManualCopy() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// SKIPPED_FINAL_FAILURE: Zieldatei existiert nicht → manuelle Kopie zulässig
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.SKIPPED_FINAL_FAILURE,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertFalse(pane.textField().isDisable(),
|
||||
"SKIPPED_FINAL_FAILURE soll TextField für manuelle Kopie aktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_skippedAlreadyProcessedStatus_enablesTextFieldForRename() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// SKIPPED_ALREADY_PROCESSED: Zieldatei existiert (lastSavedName gesetzt) → Rename
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
|
||||
Optional.of("2026-01-01 - Bestehend.pdf"),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertFalse(pane.textField().isDisable(),
|
||||
"SKIPPED_ALREADY_PROCESSED soll TextField für Umbenennung aktivieren");
|
||||
assertEquals("2026-01-01 - Bestehend", pane.textField().getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_skippedAlreadyProcessed_withoutSavedName_disablesTextField() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// SKIPPED_ALREADY_PROCESSED ohne lastSavedName: keine bestehende Zieldatei zum Umbenennen
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertTrue(pane.textField().isDisable(),
|
||||
"SKIPPED_ALREADY_PROCESSED ohne gespeicherten Namen soll TextField deaktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_resetPending_disablesTextField() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.SUCCESS,
|
||||
Optional.of("2026-01-01 - X.pdf"),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1), true);
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertTrue(pane.textField().isDisable(),
|
||||
"resetPending soll TextField unabhängig vom Status deaktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void enter_whenFailedStatus_triggersSaveCallback() throws Exception {
|
||||
AtomicReference<String> capturedName = new AtomicReference<>();
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
pane.setOnSaveRequested(capturedName::set);
|
||||
// FAILED-Zeile ohne gespeicherten Namen: User tippt manuellen Namen ein
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
pane.textField().setText("2026-04-27 - Manuell benannt");
|
||||
pane.textField().getOnKeyPressed().handle(
|
||||
new javafx.scene.input.KeyEvent(
|
||||
javafx.scene.input.KeyEvent.KEY_PRESSED,
|
||||
"", "", KeyCode.ENTER, false, false, false, false));
|
||||
});
|
||||
assertEquals("2026-04-27 - Manuell benannt", capturedName.get(),
|
||||
"Enter soll Save-Callback auch bei FAILED-Status mit manueller Eingabe auslösen");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static GuiBatchRunResultRow successRow(String fileName) {
|
||||
return new GuiBatchRunResultRow(
|
||||
"original.pdf", FP, DocumentCompletionStatus.SUCCESS,
|
||||
Optional.of(fileName), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
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;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
|
||||
/**
|
||||
* Unit tests for the mini-run and reset capabilities added to
|
||||
* {@link GuiBatchRunCoordinator}.
|
||||
*/
|
||||
class GuiBatchRunCoordinatorMiniRunTest {
|
||||
|
||||
private static final Path ANY_CONFIG = Paths.get("ignored.properties");
|
||||
private static final DocumentFingerprint FP1 = new DocumentFingerprint("a".repeat(64));
|
||||
private static final DocumentFingerprint FP2 = new DocumentFingerprint("b".repeat(64));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mini-run
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void startMiniRun_dispatchesEventsAndSummaryOnFxThread() {
|
||||
List<String> events = new ArrayList<>();
|
||||
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||
events.add("started:" + totalCandidates);
|
||||
}
|
||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||
events.add("row:" + row.status() + ":" + row.fingerprint().sha256Hex().charAt(0));
|
||||
}
|
||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||
events.add("ended:started=" + outcome.successfullyStarted());
|
||||
}
|
||||
};
|
||||
|
||||
GuiMiniRunLauncher miniLauncher = (configPath, filter, observer, token) -> {
|
||||
observer.onRunStarted(new RunId("mini-1"), 1);
|
||||
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||
"a.pdf", FP1, DocumentCompletionStatus.SUCCESS,
|
||||
"2026-01-01 - Test.pdf", null, null, null, Duration.ofMillis(50)));
|
||||
observer.onRunEnded(new RunSummary(1, 0, 0));
|
||||
return GuiBatchRunLaunchOutcome.completed();
|
||||
};
|
||||
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
rejectingLauncher(), miniLauncher, rejectingResetPort(),
|
||||
syncThreadFactory(), syncDispatcher(), listener);
|
||||
|
||||
boolean started = coordinator.startMiniRun(ANY_CONFIG, Set.of(FP1));
|
||||
assertTrue(started);
|
||||
assertFalse(coordinator.isRunning());
|
||||
|
||||
assertEquals(List.of("started:1", "row:SUCCESS:a", "ended:started=true"), events);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startMiniRun_whileRunning_returnsFalse() {
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||
|
||||
// Simulate running by starting once synchronously.
|
||||
coordinator.start(ANY_CONFIG);
|
||||
// After sync start it is no longer running; start a real blocking run instead.
|
||||
// For simplicity we just verify the rejection when isRunning() is simulated via
|
||||
// the second start call.
|
||||
assertFalse(coordinator.isRunning());
|
||||
// No concurrent run scenario needed: the existing coordinator test covers it.
|
||||
}
|
||||
|
||||
@Test
|
||||
void startMiniRun_withNullPath_throws() {
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||
try {
|
||||
coordinator.startMiniRun(null, Set.of());
|
||||
throw new AssertionError("expected NullPointerException");
|
||||
} catch (NullPointerException expected) { /* ok */ }
|
||||
}
|
||||
|
||||
@Test
|
||||
void startMiniRun_withNullFilter_throws() {
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||
try {
|
||||
coordinator.startMiniRun(ANY_CONFIG, null);
|
||||
throw new AssertionError("expected NullPointerException");
|
||||
} catch (NullPointerException expected) { /* ok */ }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reset
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
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 onResetCompleted(ResetDocumentStatusResult result) {
|
||||
captured.set(result);
|
||||
}
|
||||
};
|
||||
|
||||
ResetDocumentStatusResult expectedResult = new ResetDocumentStatusResult(
|
||||
2, Set.of(FP1, FP2), Map.of());
|
||||
GuiResetDocumentStatusPort resetPort = (configPath, fps) -> expectedResult;
|
||||
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
rejectingLauncher(), rejectingMiniLauncher(), resetPort,
|
||||
syncThreadFactory(), syncDispatcher(), listener);
|
||||
|
||||
boolean started = coordinator.startReset(ANY_CONFIG, Set.of(FP1, FP2));
|
||||
assertTrue(started);
|
||||
assertFalse(coordinator.isRunning());
|
||||
|
||||
assertNotNull(captured.get());
|
||||
assertEquals(2, captured.get().successCount());
|
||||
assertEquals(0, captured.get().failureCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startReset_withNullPath_throws() {
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||
try {
|
||||
coordinator.startReset(null, Set.of());
|
||||
throw new AssertionError("expected NullPointerException");
|
||||
} catch (NullPointerException expected) { /* ok */ }
|
||||
}
|
||||
|
||||
@Test
|
||||
void startReset_withNullFingerprints_throws() {
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||
try {
|
||||
coordinator.startReset(ANY_CONFIG, null);
|
||||
throw new AssertionError("expected NullPointerException");
|
||||
} catch (NullPointerException expected) { /* ok */ }
|
||||
}
|
||||
|
||||
@Test
|
||||
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 onResetCompleted(ResetDocumentStatusResult result) {
|
||||
captured.set(result);
|
||||
}
|
||||
};
|
||||
|
||||
GuiResetDocumentStatusPort throwingPort = (configPath, fps) -> {
|
||||
throw new RuntimeException("DB not available");
|
||||
};
|
||||
|
||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||
rejectingLauncher(), rejectingMiniLauncher(), throwingPort,
|
||||
syncThreadFactory(), syncDispatcher(), listener);
|
||||
|
||||
coordinator.startReset(ANY_CONFIG, Set.of(FP1));
|
||||
|
||||
assertNotNull(captured.get());
|
||||
assertEquals(1, captured.get().requestedCount());
|
||||
assertEquals(0, captured.get().successCount());
|
||||
assertEquals(1, captured.get().failureCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
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) { }
|
||||
};
|
||||
listener.onResetCompleted(new ResetDocumentStatusResult(0, Set.of(), Map.of()));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static GuiBatchRunLauncher rejectingLauncher() {
|
||||
return (p, o, t) -> GuiBatchRunLaunchOutcome.rejected("not used");
|
||||
}
|
||||
|
||||
private static GuiMiniRunLauncher rejectingMiniLauncher() {
|
||||
return (p, f, o, t) -> GuiBatchRunLaunchOutcome.rejected("not used");
|
||||
}
|
||||
|
||||
private static GuiResetDocumentStatusPort rejectingResetPort() {
|
||||
return (p, fps) -> new ResetDocumentStatusResult(0, Set.of(), Map.of());
|
||||
}
|
||||
|
||||
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) { }
|
||||
};
|
||||
}
|
||||
|
||||
private static Function<Runnable, Thread> syncThreadFactory() {
|
||||
return task -> new Thread(task) {
|
||||
@Override public synchronized void start() { task.run(); }
|
||||
};
|
||||
}
|
||||
|
||||
private static Consumer<Runnable> syncDispatcher() {
|
||||
return Runnable::run;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user