126 Commits

Author SHA1 Message Date
marcus 406eac80e4 Commit- und Push-Pflicht nach jeder Implementierung in Arbeitsweise ergaenzt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 07:56:42 +02:00
marcus 4fba3379b9 V3.0 freigegeben: Build 3.0.238, MSI PDF-KI-Renamer-3.0.238.msi 2026-05-05 07:31:56 +02:00
marcus 9307a18e04 #92: jdk.crypto.ec und jdk.crypto.cryptoki zu jlink-Modulliste ergaenzt
Im MSI-Betrieb schlug jede HTTPS-Verbindung zum KI-Endpoint mit
"handshake_failure" fehl. Ursache: jdeps --ignore-missing-deps
erkennt dynamisch geladene JVM-interne Module nicht. Das volle JDK
enthaelt jdk.crypto.ec (ECDH/ECDSA-Cipher-Suites fuer TLS 1.2/1.3)
und jdk.crypto.cryptoki (PKCS#11-Provider) immer; das per jlink
erzeugte Minimal-JRE im MSI-Installer enthielt sie nicht.

Fix: beide Module explizit in <addModules> aufgenommen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 19:17:34 +02:00
marcus 6a5ae4e7b0 #89: Log-Datei landet im MSI-Betrieb verlaesslich auf der Platte
Log4j2 referenziert nun ${sys:log.directory} mit nutzerschreibbarem
Fallback (~/pdf-umbenenner/logs). Die System-Property wird vor dem
ersten Logger-Zugriff aus der aktiven Konfigurationsdatei gesetzt
(EarlyLogDirectoryInitializer), damit Log4j2 bereits bei der
Erstinitialisierung den korrekten Pfad kennt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:52:35 +02:00
marcus 479d176536 #89 #90: Log-Verzeichnis-Prüfpunkt + betrieb.md MSI-Pfadwarnungen
#90: Neuer technischer Prüfpunkt LOG_DIRECTORY_USABLE (12. Checkpoint):
- Zeigt konfigurierten log.directory-Wert und aufgelösten absoluten Pfad
- Prüft ob Verzeichnis beschreibbar/anlegbar ist (WARNING, kein ERROR)
- Liest tatsächlichen Log-Datei-Pfad via Log4j2 LoggerContext → RollingFileAppender
- LogDiagnosticsPort als neuer Outbound-Port (application-Modul)
- Log4jLogDiagnosticsAdapter als Implementierung im bootstrap-Modul
- TechnicalTestRequest erhält logDirectory-Feld
- GuiTechnicalTestCoordinator erhält logDirectoryProvider-Supplier

#89: docs/betrieb.md – MSI-Betrieb um Pfadwarnungen erweitert:
- Warnung: relative Pfade lösen sich in schreibgeschütztes C:\Program Files\ auf
- Warnung: Backslashes in .properties werden als Java-Escape-Sequenzen interpretiert
- Betroffene Parameter mit Empfehlung zu absoluten Forward-Slash-Pfaden
- Beschreibung des neuen Log-Verzeichnis-Prüfpunkts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 17:02:51 +02:00
marcus bd2be347f6 #78: E2E-Tests auf sofortiges FAILED_FINAL fuer NO_USABLE_TEXT angepasst
Nach dem #78-Fix finalisiert NO_USABLE_TEXT (Foto-PDF) sofort zu FAILED_FINAL.
Die drei betroffenen E2E-Testszenarien im Bootstrap-Modul erwarteten noch
das alte FAILED_RETRYABLE-Verhalten:

- deterministicContentError_twoRuns_reachesFailedFinal umgeschrieben:
  Run 1 erwartet jetzt sofort FAILED_FINAL, Run 2 erwartet SKIPPED_FINAL_FAILURE.
- skipAfterFailedFinal_thirdRun_recordsSkip umbenannt zu _secondRun_:
  FAILED_FINAL ist nach einem Lauf erreicht, der Skip folgt im zweiten Lauf.
- mixedBatch_oneSuccess_oneContentError_batchOutcomeIsSuccess korrigiert:
  Run 1 erwartet FAILED_FINAL (nicht FAILED_RETRYABLE), Run 2 erwartet
  SKIPPED_FINAL_FAILURE (nicht einen zweiten Inhaltsfehler).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:37:36 +02:00
marcus 18f9c33bbb #78: NO_USABLE_TEXT (Foto-PDF) finalisiert sofort zu FAILED_FINAL
Bisher wurde NO_USABLE_TEXT (kein OCR-Text im PDF) wie alle anderen
deterministischen Inhaltsfehler mit der 1-Retry-Regel behandelt und
landete beim ersten Auftreten in FAILED_RETRYABLE. Da ein Bild-Scan ohne
OCR-Text sich zwischen Läufen nicht verändert, ist ein Wiederholversuch
sinnlos – der Status muss sofort FAILED_FINAL sein.

Geändert: ProcessingOutcomeTransition erkennt NO_USABLE_TEXT als
Sonderfall und liefert ohne Retry-Prüfung FAILED_FINAL. PAGE_LIMIT_EXCEEDED
und CONTENT_NOT_EXTRACTABLE behalten die 1-Retry-Regel.

Tests angepasst: Bestehende Tests, die FAILED_RETRYABLE für NO_USABLE_TEXT
erwarteten, wurden auf das korrekte Verhalten umgestellt oder auf
PAGE_LIMIT_EXCEEDED umgeschrieben. Neue Lifecycle-Tests für NO_USABLE_TEXT
(sofort FAILED_FINAL → SKIPPED_FINAL_FAILURE) hinzugefügt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:08:01 +02:00
van Elst, Marcus 349ee69a7f #85: Verwerfen im Prompt-Tab setzt Dirty-State und laedt Inhalt neu
Nach Bestaetigung des Verwerfen-Dialogs fehlte der Aufruf, der den
Tab-Zustand zuruecksetzt. Neue Methode discardChanges() in
GuiPromptEditorTab setzt loadedContent, dirty und Tab-Titel zurueck;
ist der Tab sichtbar, wird loadPromptAsync() sofort ausgeloest, sonst
greift der bestehende selectedProperty-Listener beim naechsten Oeffnen.
GuiConfigurationEditorWorkspace ruft discardChanges() nach positivem
Bestaedigungsdialog auf. Neuer Smoke-Test verifiziert das Verhalten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 12:31:18 +02:00
van Elst, Marcus 3b3e997d13 Merge fix #79: GuiPromptEditorTab Pfad-Übergabe 2026-05-04 11:46:59 +02:00
van Elst, Marcus ddfbf9b8cb #79: GuiPromptEditorTab erhaelt Konfigurationsaenderungen via notifyConfigurationChanged
Einfuehren von GuiPromptEditorPortFactory als funktionalem Interface,
damit GuiConfigurationEditorWorkspace bei jedem Laden oder Speichern
einer Konfiguration einen passenden Port fuer den Prompt-Tab erzeugen
kann. GuiPromptEditorTab.notifyConfigurationChanged() aktualisiert Port,
Pfad und maxTitleLength und setzt Dirty-State sowie Tab-Titel zurueck.
BootstrapRunner uebergibt die Factory an GuiStartupContext. Damit werden
alle vier Symptome aus #79 behoben: leerer Tab, gesperrte Textarea,
fehlgeschlagenes Speichern und fehlender Dirty-State-Indikator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 11:38:42 +02:00
marcus 0b69adf8c9 PIT-Mutation-Coverage und Cucurrent Builds konfiguriert 2026-05-03 08:13:29 +02:00
marcus 31c65fb9fd #68: Jenkinsfile-Fixes (maven-tool, cleanWs, doppelter Checkout) 2026-05-03 07:45:56 +02:00
marcus 4ee0923721 Freigabedokument V3.0 angelegt 2026-05-03 07:39:19 +02:00
marcus 4b89743404 Bedienanleitung um Verlauf-Tab, Prompt-Tab, Statuszeile und Summary-Banner ergaenzt 2026-05-03 07:35:47 +02:00
marcus 6e03093ce9 Architektur-Uebersichten um neue Ports, Use-Cases, Adapter und GUI-Komponenten ergaenzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:29:07 +02:00
marcus 51d6168697 #65: MSI-Vorbereitung - jdeps-Modulliste, winUpgradeUuid, BAT-Dateien, Pfad-Hinweise
- addModules-Liste per jdeps aktualisiert: java.prefs und jdk.unsupported.desktop
  neu hinzugefuegt; java.desktop, java.logging und java.xml manuell beibehalten
  (reflektiv genutzt, von jdeps --ignore-missing-deps nicht erkannt)
- jdeps-Ausgabe in pdf-umbenenner-packaging/jdeps-output.txt dokumentiert
- winUpgradeUuid gesetzt (EA8D0149-1401-4D3D-A98D-A2B98DAE5495); darf nie geaendert werden
- BAT-Dateien korrigiert: referenzieren nun %~dp0PDF-KI-Renamer.exe (kein Unterverzeichnis),
  passend zur appContent-Einbettung ins Installationsverzeichnis
- BAT-Dateien via appContent in den MSI-Installer eingebettet
- winShortcut=true und winShortcutPrompt=false bestaetigt (waren bereits korrekt)
- app.version=${revision} bestaetigt (war bereits korrekt nach #67)
- betrieb.md: MSI-Release-Checkliste und Pfad-Empfehlung fuer sqlite.file ergaenzt
- README-icon.md: Ist-Stand dokumentiert (icon.ico ca. 127 KB, kein Platzhalter)
- Offener Punkt fuer Marcus: Laufzeit-Verifikation ohne JDK nach manuellem MSI-Build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:08:53 +02:00
marcus 46fc1d4fa4 #7: Historien-Tab mit Liste, Detail, Filter, Status-Reset und Eintrag-Loeschen
Implementiert den vollstaendigen Historien-Tab (Verlauf) als vierten Tab der GUI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 13:57:07 +02:00
marcus 5d5dee0bbf #71: Prompt-Editor-Tab in der GUI implementieren
Neuer Tab „Prompt" in der GUI-Hauptansicht ermöglicht das Lesen, Bearbeiten
und atomare Speichern der konfigurierten KI-Prompt-Datei ohne externen Editor.

Änderungen:
- PromptSaveResult: neue sealed interface mit Saved, WriteFailed, TargetDirectoryMissing,
  AtomicMoveFailed als strukturierte Ergebnistypen für savePrompt()
- PromptPort: um savePrompt(String) erweitert (nicht mehr funktional – Teststubs angepasst)
- FilesystemPromptPortAdapter: savePrompt() mit Temp-Datei im selben Verzeichnis + ATOMIC_MOVE,
  kein stiller Fallback bei AtomicMoveNotSupportedException
- DefaultPromptEditorUseCase: Use-Case-Klasse mit loadPrompt(), savePrompt(),
  createDefaultPromptIfMissing() als Delegation an PromptPort und ResourceCreationPort
- GuiPromptEditorPort: GUI-internes Bridge-Interface (kein hexagonaler Port)
- GuiPromptEditorTab: JavaFX-Tab mit TextArea, Dirty-State-Tracking, Speichern/Reset/Anlegen,
  injizierbare threadFactory + fxDispatcher für Testbarkeit
- GuiStartupContext: um promptEditorPort erweitert; alle Backward-Compat-Konstruktoren
  und blank() mit noOpPromptEditorPort() versorgt
- GuiConfigurationEditorWorkspace: promptEditorTab integriert, Tab-Wechsel-Schutz erweitert
- BootstrapRunner: buildGuiPromptEditorPort() verdrahtet FilesystemPromptPortAdapter +
  DefaultPromptEditorUseCase; noOpGuiPromptEditorPort() für Blank-Start-Fälle
- Tests: DefaultPromptEditorUseCaseTest, FilesystemPromptPortAdapterTest (savePrompt),
  GuiPromptEditorTabSmokeTest (headless Monocle), GuiAdapterSmokeTest auf 3 Tabs aktualisiert
- docs/betrieb.md: Prompt-Tab dokumentiert, Pfad-Auflösungstabelle ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 13:13:47 +02:00
marcus 4f5ce4c750 #50: Statuszeile mit Version, Provider und Konfigurationsdateipfad
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 12:35:21 +02:00
marcus dc17824e84 #73: Summary-Banner unterhalb Fortschrittsbalken nach Laufabschluss
Neue Komponente BatchRunSummaryBanner aggregiert die Ergebnisliste nach
Laufende und zeigt je Kategorie Icon + Anzahl + Text an. Banner verschwindet
beim Start des nächsten Laufs. READY_FOR_AI, PROPOSAL_READY und PROCESSING
werden nicht gezählt (nicht im DocumentCompletionStatus-Enum enthalten);
Reset-Pending-Zeilen werden explizit ausgeschlossen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 12:22:57 +02:00
marcus 0fe5359299 #66: Tooltips auf Konfigurationstab, Verarbeitungslauf-Tab und Toolbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 12:13:00 +02:00
marcus 563d9f52db #51: Einheitliche Status-Darstellung mit Icon, Farbe und Tooltip
Neue zentrale Klasse ProcessingStatusPresentation als einzige autoritative
Quelle fuer Icons, CSS-Farben, Tooltip-Texte und Summary-Kategorielabels
aller DocumentCompletionStatus-Werte. GuiBatchRunResultRow delegiert
statusIcon() und statusColor() an diese Klasse und stellt neue Methode
statusTooltip() bereit. In GuiBatchRunTab erhalten Status-Icons Tooltips
per CellFactory; die duplizierte private statusColor()-Methode entfaellt.
Fuer FAILED_PERMANENT wird im Detailbereich ein erweiterter Erklaerungstext
gemaess Spezifikation #51 angezeigt. Unit-Tests fuer ProcessingStatusPresentation
(alle Status, Eindeutigkeit, korrekte Mapping-Werte) und statusTooltip() in
GuiBatchRunResultRowTest ergaenzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:55:11 +02:00
marcus 732d00c4ad Fix #49: Flyway-Integration mit V1-Basisskript und 3-Fall-Strategie
Ersetzt die manuelle evolveTableColumns()-Schema-Evolution durch Flyway 10.20.1.
Die Initialisierung unterscheidet drei Faelle: leere DB (Flyway-Migration),
Bestandsschema ohne Flyway-History (Baseline nach Schema-Pruefung) und
Folgestart mit Flyway-History (idempotent). Smoke-Test-Deadlock auf Windows
durch paralleles Ausgabe-Draining des Subprozesses behoben.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:44:28 +02:00
marcus 500a8c5340 #68: Jenkins-Build mit -Drevision-Übergabe und robuster Shade-JAR-Archivierung
- Neues Jenkinsfile mit pipeline-Struktur (Checkout, Version bestimmen,
  Maven Build, Archive JAR, Berichte, Artefakt ablegen, Aufräumen)
- Maven-Build übergibt -Drevision=MAJOR.MINOR.BUILD_NUMBER
- Archive-Stage: Bash explizit via #!/usr/bin/env bash + set -euo pipefail,
  mapfile-Prüfung bricht bei 0 oder mehr als 1 Shade-JAR mit Fehlermeldung ab
- MSI-Build als bewusst manuell dokumentiert (Kommentar im Jenkinsfile)
- MAJOR/MINOR via Jenkins-Parameter, EFFECTIVE_MAJOR/MINOR-Stub für State-Datei
- docs/betrieb.md: CI-Hinweis zum manuellen MSI-Build ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:43:48 +02:00
marcus c6379c04f6 #67: Konsistente Versionierung via Maven CI-friendly revision
- ${revision}-Property im Parent-POM eingeführt; alle Kind-POM-<parent>-Blöcke
  verwenden ${revision} statt hartkodierter Version
- flatten-maven-plugin 1.6.0 in <build><plugins> des Parent-POM aktiviert
  (resolveCiFriendliesOnly), sodass installierte POMs keine unaufgelösten
  ${revision}-Referenzen enthalten
- MANIFEST.MF des Shade-JARs enthält Implementation-Version und Implementation-Title
- app.version im Packaging-Modul auf ${revision} umgestellt (war 2.5.0)
- ApplicationVersionProvider: neue Utility-Klasse im Bootstrap-Modul liest
  Implementation-Version aus MANIFEST.MF, Fallback "dev" bei ungepacktem Betrieb
- ApplicationVersionProviderTest: prüft Fallback-Verhalten im Testlauf
- .gitignore: .flattened-pom.xml-Dateien ausgeschlossen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:36:55 +02:00
marcus 01e97848a7 Spezifikation für V3.0 hinzugefügt 2026-04-30 10:30:50 +02:00
marcus 8aaa3331d7 Fix #60: SHA-256-Fingerprint streaming statt Files.readAllBytes berechnen
Files.readAllBytes laedt grosse PDFs vollstaendig in den Heap und
riskiert OutOfMemoryError. Die Berechnung nutzt jetzt einen
DigestInputStream mit 8-KB-Puffer in try-with-resources. Das
Hash-Ergebnis ist bitidentisch zur vorigen Implementation, die
Exception-Semantik bleibt unveraendert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 06:21:25 +02:00
marcus d10a572b50 Fix #54: Modellabruf ueber Generation-Counter gegen veraltete Ergebnisse absichern
Bei mehrfachem Provider-Wechsel oder Modelle-Neu-Laden konnten parallele
HTTP-Threads ihre Ergebnisse in dieselbe Meldungsliste schreiben. Mit
einem AtomicLong-Generationszaehler wird vor jedem Lauf eine Generation
festgehalten; bei der UI-Auslieferung auf dem JavaFX Application Thread
wird verworfen, was nicht mehr zur aktuellen Generation gehoert. Damit
ueberschreiben veraltete Worker den UI-Zustand nicht mehr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 06:21:15 +02:00
marcus a87c73401b Fix #53: Konfigurations-Oeffnen ueber Single-Thread-Executor serialisieren
Statt fuer jede openConfigurationFile-Anfrage einen neuen Thread zu
starten, werden Anfragen jetzt ueber einen Single-Thread-ExecutorService
mit Daemon-ThreadFactory eingereiht. Mehrfaches Klicken auf Oeffnen
erzeugt keine konkurrierenden Worker-Threads mehr; Anfragen werden
seriell hintereinander abgearbeitet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 06:21:05 +02:00
marcus 8ca6d08133 Fix #55: Mutable Felder in PdfPreviewPane als volatile deklarieren
Die Felder currentDocument, currentRenderer, currentSourceFile,
currentPage und totalPages werden vom Worker-Thread geschrieben und
vom JavaFX Application Thread gelesen. Das volatile-Keyword garantiert
nun die Sichtbarkeit zwischen den Threads gemaess Java Memory Model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 06:20:56 +02:00
marcus cd273505af Fix #56: Inkonsistente log4j-slf4j Artifact-ID vereinheitlichen
Die Dependency log4j-slf4j-impl mit hartcodierter Version wurde durch
log4j-slf4j2-impl ersetzt und nutzt jetzt einheitlich die zentrale
Versionsverwaltung im Parent-POM, konsistent zu den anderen Modulen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 06:20:36 +02:00
marcus bdc5e8331f Doku: Modulare Architektur-Dokumentation (#34)
Drei neue Architektur-Übersichten unter docs/architecture/ angelegt
(domain-overview, gui-overview, adapter-overview), die das bisher in
CLAUDE.md verstreute Detailwissen zu Paketen, Schlüsselklassen, Ports
und Bootstrap-Verdrahtung pro Modulbereich bündeln. CLAUDE.md verweist
auf die drei Dateien und enthält das Detailwissen nicht mehr selbst,
sodass Arbeit an einem Modulbereich mit CLAUDE.md plus der jeweils
passenden Übersicht auskommt. Workpackage-Liste um M14 und M15 ergänzt;
V2.9-Implementierungsstand auf Modul-/Verhaltensebene konsolidiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:21:58 +02:00
marcus 330bcfe124 Fix #63: Datei-Filter in pickFile wirksam machen
Neues funktionales Interface FilePickerDialog eingefuehrt, das Titel,
Anfangspfad und ExtensionFilter-Liste entgegennimmt. showNativeFileChooser
wendet die Filter auf den FileChooser an. pickFile reicht die Filter
durch. Test-Stubs verwenden die aktualisierte Drei-Parameter-Signatur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:57:25 +02:00
marcus c137d9e02e Fix #61: Connection-Leak in SqliteUnitOfWorkAdapter beheben
Connection wird jetzt in try-with-resources geoeffnet, sodass sie
auch dann zuverlaessig geschlossen wird, wenn setAutoCommit(false) wirft.
Rollback-Behandlung bleibt unveraendert innerhalb des inneren catch-Blocks.
Ebenfalls: korrekten Import fuer DateTimeFormatter ergaenzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:52:21 +02:00
marcus ea8b94acc7 Fix #57 Fix #58: Catch auf DocumentPersistenceException einengen
Catch in ResolveHistoricalDocumentContext und ResolveHistoricalFileName
praezisiert: nur DocumentPersistenceException wird abgefangen, geloggt
(WARN) und mit leerem Optional beantwortet. Andere RuntimeExceptions
propagieren weiter zum Aufrufer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:52:13 +02:00
marcus 4bbee57d41 Fix #58: Verschluckte Exception in ResolveHistoricalFileName loggen
Catch von Exception auf RuntimeException eingeengt; unerwartete
Laufzeitfehler werden jetzt per logger.warn() protokolliert und
weiterpropagiert statt still verschluckt zu werden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:43:52 +02:00
marcus 43c54923f8 Fix #57: Verschluckte Exception in ResolveHistoricalDocumentContext loggen
Log4j2-API als Abhaengigkeit im Application-Modul ergaenzt. Catch von
Exception auf RuntimeException eingeengt; unerwartete Laufzeitfehler
werden jetzt per logger.warn() protokolliert und weiterpropagiert statt
still verschluckt zu werden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:43:45 +02:00
marcus a910633c64 Fix #64: Obergrenze für .bak-Backup-Schleife
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-28 15:37:36 +02:00
marcus 899525a75c Fix #59: Legacy-Format-Schutz für Instant-Parsing in ProcessingAttempt-Repository
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-28 15:36:48 +02:00
marcus 0a139193b4 Fix #62: Leere Bootstrap.java löschen
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-28 15:30:23 +02:00
marcus 0da80849d4 Fix #52: Race Condition auf pendingMessages beim Test-Trigger
pendingMessages.clear() wurde aus runTechnicalTestsAction() in den
Coordinator verlagert (erste Anweisung in triggerTechnicalTests()).
Damit liegen clear() und Worker-Start auf demselben Thread (FX),
und das Race-Fenster zwischen clear() und den per Platform.runLater
zurueckgefuehrten add()-Aufrufen entfaellt.

Die fachliche Produktionssemantik (Replace beim Trigger) bleibt
identisch. JavaDoc und Kommentare wurden auf Replace-Semantik
korrigiert. Der Smoke-Test trigger_twice_accumulatesTestEntries
wurde zu trigger_twice_replacesTestEntries umbenannt und prueft
nun die Replace-Erwartung des isolierten Coordinators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:28:18 +02:00
marcus 014b11abd2 Doku: R4 Dokumentations-Review umgesetzt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:04:56 +02:00
marcus 6ff463b7ef Repository aufräumen 2026-04-28 09:57:11 +02:00
marcus 8bb0aabb51 Code aufräumen 2026-04-28 08:52:59 +02:00
marcus 27b4292c2f Imports aufgeräumt 2026-04-28 08:44:24 +02:00
marcus 0b5a441a5d Fix #44: Ersetze Status-Icons durch Unicode-Symbole mit Farben
- SUCCESS: ✓ (U+2713) mit #2e7d32 (grün)
- FAILED_RETRYABLE: ↻ (U+21BB) mit #d98200 (orange)
- FAILED_PERMANENT: × (U+00D7) mit #c62828 (rot)
- SKIPPED_ALREADY_PROCESSED: ≡ (U+2261) mit #1565c0 (blau-grau)
- SKIPPED_FINAL_FAILURE: ⊘ (U+2298) mit #757575 (grau)

Die neue Methode statusColor() gibt CSS-Hex-Farben zurück.
Font: 16px, font-weight: bold

Betroffene Dateien:
- GuiBatchRunResultRow.java: neue statusColor() Methode
- GuiBatchRunResultRowTest.java: Icons aktualisiert
- GuiBatchRunCoordinatorTest.java: Icons aktualisiert
- GuiBatchRunTabSmokeTest.java: Icons aktualisiert

Alle 323 Tests bestanden.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-28 07:55:49 +02:00
marcus 3877359b42 Fix #24: Meldungs-ListView dehnt sich vertikal aus
setMaxHeight(200) entfernt und VBox.setVgrow(messagesListView, ALWAYS)
gesetzt. Die ListView füllt nun den verbleibenden Platz innerhalb der
Meldungs-Card; der Button darunter bleibt davon unberührt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 07:35:21 +02:00
marcus 769d15fd86 Fix #24: Meldungsbereich füllt verbleibenden vertikalen Platz
VBox.setVgrow(card, ALWAYS) auf dem Meldungs-Card macht die Sektion
dehnbar innerhalb von sectionsBox. Damit das VGrow überhaupt wirken kann,
wurde scrollPane.setFitToHeight(true) ergänzt – ohne diese Voraussetzung
bleibt tabContent auf seine natürliche Höhe beschränkt und das VGrow
läuft ins Leere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 07:28:10 +02:00
marcus 6317a27378 Fix #24: GridPane-Platzhalter in Provider-Block auf managed=false gesetzt
Die leeren Label-Spacer in Spalte 0 der Fehler- und Info-Zeilen des
fieldGrid waren managed=true (JavaFX-Default). JavaFX reservierte dadurch
pro Zeile ~20px Hoehe, selbst wenn das eigentliche Fehler-Label in
Spalte 1 unmanaged/unsichtbar war. Fuenf betroffene Zeilen (baseUrl-Fehler,
Timeout-Fehler, Modell-Fehler, API-Key-Fehler, API-Key-Herkunft) erzeugten
zusammen ~96-120px ungenutzten Leerraum unterhalb der Felder.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 07:20:43 +02:00
marcus 4fa4c152a5 Fix #24: Provider-Bereich kompakter – VBox-Spacing auf 2px, unteres Padding auf 4px 2026-04-28 06:56:00 +02:00
marcus ec23b2455a Fix #24: Provider-Bereich kompakter – Spacing und Padding an Pfade-Bereich angepasst
- VBox-Spacing in Provider-Block von 2 auf 4 Pixels erhöht (wie im Pfade-Bereich)
- Padding in der Provider-Box von 6px auf 8px erhöht (wie im createCardContainer)
- Abstände zwischen Basis-URL, Modell und API-Key sind nun einheitlich mit dem Pfade-Bereich

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-27 19:04:02 +02:00
marcus 7f2cccf317 Fix #35: Einzelinstanz-Schutz ueber Loopback-ServerSocket
Eine zweite parallele Instanz wird beim Start abgewiesen. Der Schutz
greift fuer GUI- und Headless-Pfad gleichermassen vor der Modusweiche
in BootstrapRunner.

Umsetzung als ServerSocket-Bind auf 127.0.0.1:47832: stale-lock-frei,
da das Betriebssystem den Port beim Prozessende automatisch freigibt,
robust unter Windows mit gemappten Laufwerken und UNC-Pfaden, und ohne
Konflikt mit dem bestehenden RunLockPort, der nur den Batch-Lauf
schuetzt. Bei kollidierender Bindung erscheint im GUI-Modus ein
Swing-Dialog (JavaFX ist hier noch nicht initialisiert) und im
Headless-Modus eine Logmeldung; beide Pfade enden mit Exit-Code 1.
Ein ShutdownHook und try-with-resources geben den Port deterministisch
wieder frei.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:11:54 +02:00
marcus a5fae8cf55 Fix #42: KI-Prompt weist explizit zur Kuerzung bei Zeichenlimit an
Wenn die KI einen Titel nahe am Zeichenlimit erzeugt, kam es regelmaessig
zur Limit-Ueberschreitung um wenige Zeichen, was den Lauf in
FAILED_RETRYABLE und nach Wiederholung in FAILED_FINAL trieb.

Der Default-Prompt enthaelt jetzt eine explizite Kuerzungsanweisung
(Abkuerzungen, kompaktere Datumsformate, Weglassen unwichtiger Details)
mit dynamisch eingesetztem Zeichenlimit. Die bestehende Validierung
in AiResponseValidator bleibt als Sicherheitsnetz unveraendert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:11:42 +02:00
marcus 191d398604 Fix #44: Differenzierte Icons fuer alle Verarbeitungsstatus
Die beiden SKIPPED-Statuswerte teilten sich bisher Icon und Farbe.
Jeder Status erhaelt jetzt ein eigenes Unicode-Icon und passende Farbe:
SUCCESS gruen, FAILED_RETRYABLE orange, FAILED_PERMANENT rot,
SKIPPED_ALREADY_PROCESSED blau (Naechster-Track), SKIPPED_FINAL_FAILURE
grau (Durchgestrichener Kreis).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:11:32 +02:00
marcus f204ad1f1e Fix #24 (fortgesetzt): Provider-Bereich kompakter, Meldungen kleiner, Abstände reduziert
- Basis-URL und Timeout stehen jetzt nebeneinander (4-Spalten-Layout,
  Timeout schmal rechts), Modell und API-Key belegen jeweils volle Breite
- Meldungsbereich: minHeight/prefHeight von 140px auf 60px reduziert
- Sektionsabstände (sectionsBox spacing) von 12 auf 4px reduziert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:40:27 +02:00
marcus ac3513504d Fix #24 (fortgesetzt): Restliche Bereiche der Konfigurationsseite kompakter
Verbesserungen für kompakteres Layout:
- createCardContainer(): spacing 8→4, padding 12px→8px
- createFieldGrid(): vgap 8→4 (reduziert vertikale Abstände)
- createProviderBlock():
  * spacing 8→2, padding 10px→6px
  * Basis-URL, Modell, Timeout, API-Key in kompaktem GridPane
  * Reduzierter vertikaler Abstand zwischen Feldern
- createProcessingLimitsSection():
  * Umgestellt auf 2-Spalten-GridPane für Feldgruppen
  * Max. Seiten + Max. Zeichen nebeneinander
  * Max. Titellänge + Max. Retries nebeneinander
  * Log-Level + Sensible KI-Ausgabe nebeneinander
- Abstände zwischen Sektionen global reduziert:
  * sectionsBox spacing: 12→6
  * tabContent spacing: 8→4

Ziel: Konfigurationsseite passt jetzt komplett auf 1920x1080 ohne Scrollen.
Alle Kommentare auf Deutsch.

Build: .\mvnw.cmd clean verify -pl pdf-umbenenner-adapter-in-gui --also-make
Build-Status: ERFOLGREICH (322 Tests bestanden)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-27 15:28:11 +02:00
marcus 65d8379c15 Fix #24 (teilweise): Pfade-Bereich kompakter gestalten
- Quellordner + Zielordner nebeneinander in 2-Spalten-Layout
- SQLite-Datei + Prompt-Datei nebeneinander in 2-Spalten-Layout
- Vertikale Abstände zwischen Feldern reduziert (von 0 zu 4)
- Lock-Datei und Log-Verzeichnis in ausklappbare TitledPane verschoben
  (standardmäßig eingeklappt, Label: "Weitere Optionen (Click zum Aufklappen)")
- Neue Hilfsmethode buildTwoPathFieldsRow() für 2-Spalten-Pfad-Layouts
- Import für TitledPane hinzugefügt
- Alle Kommentare auf Deutsch

Build: .\mvnw.cmd clean verify -pl pdf-umbenenner-adapter-in-gui --also-make
Build-Status: ERFOLGREICH (322 Tests bestanden)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-27 15:17:56 +02:00
marcus a3642608b4 Fix #48: Fehlerbehandlung für Legacy-Datumsformat in stringToInstant()
Fehler: stringToInstant() in SqliteDocumentRecordRepositoryAdapter verwendete
nur Instant.parse(), das nur ISO-8601 versteht. Ältere DB-Einträge haben aber
das Format 'yyyy-MM-dd HH:mm:ss' (Leerzeichen statt T, kein Z).

Lösung: Fallback-Logik implementiert
1. Versuch: Instant.parse() für ISO-8601 Format
2. Fallback: DateTimeFormatter für 'yyyy-MM-dd HH:mm:ss' → als UTC parsen

Die Methode bleibt robust und gibt null zurück, wenn beide Parser fehlschlagen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-27 14:33:36 +02:00
marcus ff86a07f0e Fix #48: Korrekte Abschlussmeldung bei SKIPPED-only-Läufen und neutrale Farbe
Problem 1: Falsche Meldung bei reinen SKIPPED-Läufen (0 failed, >0 skipped)
- Die Nachricht enthielt immer "fehlgeschlagen" auch wenn failedCount=0
- Neue Logik formuliert Meldung basierend auf tatsächlichen Zählern

Problem 2: Falsche rote Farbe für SKIPPED-only-Läufe
- Farblogik basierte auf Keywords im Text statt auf failedCount
- Neue Logik färbt rot nur wenn failedCount > 0
- Farbe neutral (kein Hintergrund) für SKIPPED-only-Läufe

Neue buildSummaryMessage()-Methode mit drei Fällen:
- Alle erfolgreich (0 failed, 0 skipped)
- Nur übersprungen (0 failed, >0 skipped)
- Mit Fehlern (failed > 0)

Neue Farblogik in appendSummary() direkt auf failedCount basieren.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-27 14:15:18 +02:00
marcus d9670ddfbe Fix #47: Hinweisbereich im Verarbeitungslauf-Tab verbessert
- Padding unterhalb der Selektions-Button-Leiste ergaenzt
- Hinweisbereich wird nur eingeblendet wenn eine Meldung vorliegt
- Farbliche Unterscheidung: Erfolg gruen, Fehler rot, neutral Standard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 14:01:32 +02:00
marcus 03b23eb6a9 Fix #46: Button 'Zuruecksetzen auf KI-Vorschlag' wird korrekt deaktiviert
Der Reset-Button wird jetzt explizit deaktiviert, wenn kein KI-Vorschlag
vorhanden ist (finalFileName ist Optional.empty()). Die Bedingung in
refreshUiState() wurde geklaert und dokumentiert.
2026-04-27 13:51:36 +02:00
marcus 1d77173c49 Fix #31: Manuelle Dateinamen-Eingabe für nicht verarbeitete Dateien
Nicht-erfolgreiche Zeilen (FAILED, FAILED_RETRYABLE, SKIPPED_FINAL_FAILURE)
können im Detailbereich des Verarbeitungslauf-Tabs nun einen manuellen
Zieldateinamen erhalten. Beim Bestätigen wird die Quelldatei mit dem
benutzerdefinierten Namen ins Zielverzeichnis kopiert und der Stammsatz
atomar auf SUCCESS gehoben.

Neuer Inbound-Port ManualFileCopyUseCase mit sealed Result-Hierarchie,
Default-Implementierung mit Best-Effort-Rollback bei Persistenzfehler
sowie GUI-Brücke GuiManualFileCopyPort. Die GUI entscheidet anhand des
Status zwischen Umbenennen (SUCCESS, SKIPPED_ALREADY_PROCESSED) und
Kopieren (FAILED_*, SKIPPED_FINAL_FAILURE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:22:44 +02:00
marcus fb0e9809f6 Fix #45: Fehlender Abstand unterhalb der PDF-Navigationsbuttons
Die Buttons 'Vorherige' und 'Naechste' in der PDF-Vorschau hatten keinen
Abstand nach unten zur Trennlinie/zum Panel-Rand. Padding unten am navBar-
Container (HBox) hinzugefügt, konsistent mit dem Padding oben.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-27 12:47:58 +02:00
marcus c3f8103572 Fix #43: Benutzerfreundliche Fehlermeldungen bei FAILED-Einträgen im Detailbereich
Jeder Fehlertyp erhält jetzt eine präzise deutsche Meldung:
- Kein lesbarer Text (NO_USABLE_TEXT) → OCR-Hinweis
- Titel zu lang → Titeltext + tatsächliche Länge + Limit
- Defekte/nicht extrahierbare PDF → Beschädigungshinweis
- Verbindungsfehler/Timeout → Verbindungs- und Konfigurationshinweis
- Unbekannter Fehler → neutraler Fallback ohne Log-Verweis

Der Verweis auf "Details im Anwendungslog" wurde vollständig entfernt.
Das "Fehler:"-Präfix in buildDetailText() entfällt; bei vorhandener
Fehlermeldung wird NO_REASONING_TEXT unterdrückt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 12:24:50 +02:00
marcus 3f5602de01 Fix #30: Detailbereich bei SKIPPED-Zeilen mit historischen Informationen befüllen
- Teile DocumentCompletionStatus.SKIPPED in SKIPPED_ALREADY_PROCESSED und
  SKIPPED_FINAL_FAILURE auf, um den Skip-Grund unterscheidbar zu machen
- Führe neuen Typ HistoricalDocumentContext ein (lastTargetFileName,
  lastSuccessInstant, lastFailureInstant, wasEverSuccessful)
- Führe ResolveHistoricalDocumentContextUseCase und
  DefaultResolveHistoricalDocumentContextUseCase ein
- Ersetze GuiHistoricalFileNamePort durch GuiHistoricalDocumentContextPort
- Lade historischen Kontext für übersprungene Zeilen im Coordinator-Worker-Thread
- Zeige im Detailbereich je nach Skip-Grund:
  SKIPPED_ALREADY_PROCESSED: "Bereits erfolgreich verarbeitet am [Datum]. Zieldatei: [Name]."
  SKIPPED_FINAL_FAILURE: "Endgültig fehlgeschlagen am [Datum]. Erneute Verarbeitung nur nach Reset möglich."
- Passe alle betroffenen Tests an

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 12:00:27 +02:00
marcus 1db6e27be8 Fix #41: Historischen KI-Dateinamen für übersprungene Dokumente in Ergebnistabelle anzeigen
Neue Komponenten:
- ResolveHistoricalFileNameUseCase (port/in) und DefaultResolveHistoricalFileNameUseCase (usecase)
- GuiHistoricalFileNamePort (GUI-interner Port, folgt dem Muster von GuiManualFileRenamePort)

GuiBatchRunCoordinator ruft in toRow() für SKIPPED-Zeilen ohne finalName den
historicalFileNamePort auf und trägt den Rückgabewert als neuen Dateinamen ein.

Bootstrap verdrahtet resolveHistoricalFileNameForGui als GuiHistoricalFileNamePort
und übergibt ihn über GuiStartupContext an den GUI-Adapter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 10:54:31 +02:00
marcus 385bda5331 Fix #39, #40: Behebe UX-Bugs im Detailbereich des Tabs Verarbeitungslauf
Issue #40: Entferne Platzhaltertext aus Dateiname-Eingabefeld
- Der promptText "Basisname ohne .pdf" wird nicht mehr angezeigt,
  wenn keine Zeile selektiert ist
- Das Feld bleibt leer und ausgegraut im deaktivierten Zustand

Issue #39: Addiere fehlende rechte Padding zu Detailbereich
- Der Detailbereich hatte nur links Padding (SECONDARY_SPACING),
  aber nicht rechts
- Jetzt ist das Padding symmetrisch auf beiden Seiten

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-27 10:29:38 +02:00
marcus 5d4230b4cb Fix #25: Entferne statisches .pdf-Label neben Dateinamen-Eingabefeld
Das überflüssige Label rechts neben dem Dateinamen-Eingabefeld in der
FileNameEditorPane wird entfernt. Die Dateiendung ist implizit bekannt.

- Entferne Feldinitialisierung extensionLabel
- Entferne Label aus HBox inputRow
- Entferne entsprechende Initialisierungen
- Aktualisiere JavaDoc-Kommentar

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-27 09:58:05 +02:00
marcus 3feafcbce8 Fix #36: Falscher Verwerfen-Dialog beim Klick auf Dateiname uebernehmen
Nach erfolgreicher Umbenennung loeste resultItems.set() in
upsertResultRowByFingerprint() den selectedItemProperty-Listener aus,
der handleSelectionChange() mit noch aktivem Dirty-State aufrief.

Drei Korrekturen:
1. fileNameEditor.clearDirtyState() in handleRenameResult() vor dem
   Zeilen-Upsert: setzt lastSavedName = aktueller Textfeldinhalt, damit
   isDirty() false ist bevor die Tabellenzeile ersetzt wird.
2. selectionSyncInProgress-Schutz um resultItems.set() in
   upsertResultRowByFingerprint(): unterbindet mehrfache JavaFX-interne
   Change-Events (oldRow > null > newRow) waehrend des Upserts.
3. Neue Methode FileNameEditorPane.clearDirtyState() eingeführt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:47:58 +02:00
marcus 5165ea6f1d Icons hinzugefügt. 2026-04-25 08:26:26 +02:00
marcus 0e20f93c0d Feature #21/#20: Anwendungs-Icon und System-Tray einbinden
Schließt Issue #21: Alle vier Icon-Größen (16/32/64/128 px) werden beim
Start am primären Stage gesetzt; JavaFX wählt automatisch die passende
Größe je nach Kontext (Titelleiste, Taskleiste, Alt+Tab).

Schließt Issue #20: Beim Klick auf den X-Button wird das Fenster in den
Windows System-Tray minimiert (stage.hide()) statt die Anwendung zu
beenden. Platform.setImplicitExit(false) hält die JavaFX-Runtime aktiv.
Das Tray-Icon zeigt ein Kontextmenü mit "Öffnen" und "Beenden";
Doppelklick öffnet das Fenster ebenfalls. Beim Beenden über das Tray-Menü
wird das Icon sauber entfernt.

Die gesamte AWT-Tray-Logik ist in SystemTrayManager gekapselt. Der
Headless-Betrieb bleibt unberührt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 08:24:54 +02:00
marcus 234b3461b7 Doku #34: Dokumentation auf V2.9-Stand aktualisieren
- CLAUDE.md: Aktiver Implementierungsstand auf V2.9 aktualisiert;
  neue Klassen (PdfPreviewPane, FileNameEditorPane, ManualFileRenameUseCase,
  FilesystemTargetFileRenameAdapter, GuiManualFileRenamePort) und neuer Port
  (TargetFileRenamePort) dokumentiert; PDFBox-Direktrendering, Vollbild-Start
  und automatisches Laden der letzten Konfiguration beschrieben
- README.md: Versionshinweis auf V2.9 aktualisiert; neue Features genannt
- docs/betrieb.md: Startverhalten (Vollbild, letzte Konfiguration automatisch laden)
  ergaenzt; GUI-Tab-Beschreibung um PDF-Vorschau und Dateiname-Editor erweitert
- docs/gui-bedienanleitung.md: Abschnitt 2.1 fuer automatisches Laden aktualisiert;
  neuer Abschnitt 13b fuer PDF-Vorschau und editierbaren Dateiname-Bereich
- docs/befundliste.md: V2.9-Fixes (#27, #28, #29, #33) dokumentiert
- docs/specs/technik-und-architektur.md: TargetFileRenamePort in Port-Liste
  ergaenzt; PDFBox-Direktrendering im Adapter-Out-Abschnitt erwaehnt
- docs/specs/fachliche-anforderungen.md: Nicht-Ziele praezisiert;
  neuer Abschnitt 14a fuer manuelle Dateiname-Korrektur nach Verarbeitungslauf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:56:13 +02:00
marcus 6b078aa3e7 Fix #33: Letzte Konfigurationsdatei beim Neustart automatisch laden
Nutzt java.util.prefs.Preferences mit dem Schluessel "lastConfigPath"
um den Pfad der zuletzt geladenen Konfigurationsdatei zu speichern.

Beim naechsten Start wird diese Datei automatisch geladen, sofern sie
noch existiert. Falls nicht oder falls nie eine Datei geladen wurde,
startet die GUI normal ohne Fehlermeldung.

Geaenderte Klassen:
- GuiConfigurationEditorWorkspace: Speichern des Pfads nach erfolgreichem Laden,
  neue Methode autoLoadLastConfiguration() fuer automatisches Laden beim Start
- PdfUmbenennerGuiApplication: Aufruf von autoLoadLastConfiguration() nach
  Initialisierung des Fensters

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 16:27:52 +02:00
marcus 7e2fec4c7b Fix #28: Anwendung standardmaessig im Vollbild starten
Fuegt stage.setMaximized(true) in PdfUmbenennerGuiApplication.start() hinzu,
so dass das Fenster beim Start automatisch maximiert wird.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 16:22:51 +02:00
marcus 591c7ff94c Fix #29: Eigenes PDF-Rendering mit PDFBox statt PDFViewFX
Ersetzt die PDFView-basierte Vorschau durch direktes Rendering einzelner Seiten
mit PDFBox (Loader.loadPDF + PDFRenderer.renderImageWithDPI bei 120 DPI).
BufferedImage wird über SwingFXUtils.toFXImage in eine JavaFX-Image konvertiert
und in einer ImageView angezeigt. fit-to-view entsteht nativ durch Binding von
fitWidth/fitHeight an den StackPane-Bereich bei preserveRatio=true. Keine
Scrollbalken, keine Zoom-Einschraenkungen, Seitenanfang immer sichtbar.

Lazy Rendering mit In-Memory-Cache fuer bereits gerenderte Seiten; asynchrones
Oeffnen und Rendering auf pdf-preview-worker-Thread; "latest preview request
wins"-Prinzip bleibt erhalten. pdfviewfx-Abhaengigkeit aus adapter-in-gui pom
entfernt, pdfbox stattdessen explizit aufgenommen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:05:02 +02:00
marcus 673023d921 Fix #29: Fit-to-view via statischem A4-Fallback statt asynchronem Seitenverhältnis
Ersetzt fetchAspectRatioAsync/fitToView durch updateZoom() mit A4-Dimensionen
(595x842 Punkte) als Fallback. Scrollbalken werden per CSS und ScrollPane-Policy
ausgeblendet. Zoom-Listener auf pdfView statt viewStack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:51:26 +02:00
marcus 71d79ab30c Fix #29: Layout-Umbau und fit-to-view PDF-Vorschau ohne Scrollbalken
GuiBatchRunTab: Buttons "Erneut verarbeiten" / "Status zurücksetzen" und
Meldungsbereich in die linke SplitPane-Spalte unterhalb der Tabelle
verschoben. Detailbereich (rechte Spalte) erstreckt sich dadurch vollständig
von oben bis unten – mehr Platz für die PDF-Vorschau.

PdfPreviewPane: Gesamten suppressScrollReset / ChangeListener-Code entfernt.
Seite wird jetzt immer fit-to-view ohne Scrollbalken angezeigt: Seitenverhältnis
wird asynchron per renderPage(0.05f) ermittelt, Zoom über setZoomFactor() gesetzt
und bei Größenänderungen der Anzeigefläche automatisch neu berechnet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:28:29 +02:00
marcus 8f4e18b248 Fix #29: Seitenanfang zuverlaessig via vvalue-Einmal-Listener erzwingen
Der bisherige ImageView-imageProperty-Listener mit Platform.runLater()
wurde von PDFViewFX nach dem Rendering noch einmal ueberschrieben, weil
die interne Scroll-Korrektur ebenfalls asynchron laeuft und spaeter
ausgefuehrt wird.

Neuer Ansatz: Nach jedem pdfView.load() und pdfView.setPage()-Aufruf
wird ein einmaliger ChangeListener auf die vvalueProperty des internen
ScrollPane registriert (scheduleScrollToTop). Sobald PDFViewFX seine
interne Scroll-Position durchschreibt und der Wert von 0 abweicht,
korrigiert der Listener ihn sofort auf 0 und entfernt sich danach selbst.
Damit greift der Eingriff immer nach dem internen PDFViewFX-Scroll,
unabhaengig von der Renderzeit.

Zusaetzlich wird ein aktiver Listener bei schnellen Seitenwechseln
(cancelScrollToTopListener) und bei clear() sauber aufgeraeumt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:58:43 +02:00
marcus 0387be0e96 Fix #27 und #29: pdfviewfx 3.3.2 und zuverlaessiger Seitenanfang via ImageView-Listener
pdfviewfx wird von 3.1.1 auf 3.3.2 aktualisiert. Version 3.3.1 behebt
'Do not interrupt rendering', wodurch ClosedByInterruptException bei
schnellem Seitenwechsel (#27 Folge-Bug) und das Ausbleiben weiterer
Renderings ab Seite 3+ (#29 Folge-Bug) nicht mehr auftreten.

Das 100-ms-PauseTransition-Workaround fuer den Seitenanfang wird ersetzt
durch einen Listener auf die imageProperty des internen ImageView der
PDFView-Skin. Der Listener scrollt erst dann zum Seitenanfang, wenn
das Rendering tatsaechlich abgeschlossen ist und pendingScrollToTop
gesetzt wurde (bei loadSource und Seitenwechsel-Buttons). Dadurch wird
der Seitenanfang zuverlaessig angezeigt, unabhaengig von der Renderzeit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:30:40 +02:00
marcus ca16855e81 Fix #27 und #29: Gezielter Scroll-Schutz und zuverlaessiger Seitenanfang
Bug #27: Den zu aggressiven ScrollEvent::consume-Filter durch einen
gezielten Filter auf dem internen ScrollPane der PDFView-Skin ersetzt.
Der Filter konsumiert nur dann, wenn die Seite keinen ueberlaufenden
Inhalt hat oder der Scroll-Inhalt an der oberen bzw. unteren Grenze
angekommen ist. Dadurch bleibt Inhalts-Scrolling innerhalb einer Seite
weiterhin moeglich; nur der Seitenwechsel per Mausrad wird verhindert.

Bug #29: Platform.runLater() durch eine PauseTransition (100 ms) ersetzt,
die nach dem vollstaendigen Rendering-Durchlauf der PDFView-Skin den
internen ScrollPane explizit auf vValue=0 zuruecksetzt. So wird der
Seitenanfang zuverlaessig angezeigt, ohne dass die Skin die Position
nachtraeglich ueberschreibt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:50:43 +02:00
marcus 7e31057bfa Fix #27 und #29: Mausrad-Seitenwechsel und Seitenanfang in PDF-Vorschau
Bug #27: ScrollEvent-Filter auf PDFView verhindert Seitenwechsel durch Mausrad.
Seitenwechsel sind ausschliesslich ueber die Navigations-Buttons moeglich.
Die Seitenzahl wird nur noch bei Button-Klick aktualisiert.

Bug #29: Nach dem Laden einer PDF und bei Seitenwechsel ueber Buttons wird
die Seite jetzt explizit von oben angezeigt. Der setPage()-Aufruf erfolgt
via Platform.runLater() nach dem Layout-Pass, sodass stets der Seitenanfang
sichtbar ist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:30:53 +02:00
marcus d3fbfc4094 V2.9: Integrierte PDF-Vorschau und editierbarer Dateiname im Verarbeitungslauf
Neu im Tab "Verarbeitungslauf":
- Integrierte PDF-Vorschau der Quelldatei mit Lazy Rendering (Seite 1 sofort,
  weitere Seiten on-demand), Cache pro Selektion, "latest preview request wins"
- Editierbarer KI-Dateinamenvorschlag mit Live-Validierung, Dirty-State-Dialog
  bei Zeilen-/Tabwechsel, Schließen und Laufstart, atomare FS+DB-Transaktion
  inkl. Rollback und Fingerprint-basierter Konfliktauflösung

Architektur:
- Neuer Application-Use-Case ManualFileRenameUseCase und Outbound-Port
  TargetFileRenamePort mit Filesystem-Adapter
- Neuer GuiManualFileRenamePort, verdrahtet im Bootstrap
- GuiBatchRunResultRow um correctedFileName erweitert
- GuiBatchRunTab auf SplitPane-Layout (60/40) umgebaut, Detail-Panel mit
  KI-Begründung, FileNameEditorPane und PdfPreviewPane
- Spike-Code (PdfViewerSpike) entfernt, produktive Implementierung ersetzt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:30:55 +02:00
marcus f6b265b370 Spezifikation für V2.9 (PDF Ansicht + manuelles Umbenennen hinzugefügt 2026-04-24 11:47:02 +02:00
marcus 3a98304a5c Refactoring #19: Benutzerfreundliche deutsche KI-Fehlermeldungen im Begründungsbereich
Neue Klasse AiFailureMessageTranslator übersetzt technische englische
Fehlertexte (z.B. HTTP-Statuscodes, Verbindungsfehler) in lesbare
deutsche Benutzerhinweise. GuiBatchRunTab nutzt den Translator vor der
Anzeige; das Datenmodell (aiFailureMessage) bleibt unverändert.
Unit-Tests decken alle definierten Mapping-Fälle ab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 08:25:46 +02:00
marcus b87e8498e6 Fix #19: Fehlergrund bei fehlgeschlagenem KI-Aufruf im Begründungsbereich anzeigen
- DocumentCompletionEvent um optionales Feld failureMessage erweitert
- DocumentProcessingCoordinator leitet Fehlermeldung bei Fehler-Status durch
- GuiBatchRunResultRow um aiFailureMessage (Optional<String>) ergänzt
- GuiBatchRunCoordinator.toRow() befüllt aiFailureMessage aus dem Event
- GuiBatchRunTab.buildDetailText() zeigt bei fehlendem Reasoning und
  vorhandenem Fehlergrund: "⚠ Fehler: <Meldung>" vor dem Hinweistext
- Alle Tests angepasst und neue Unit-Tests für aiFailureMessage ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 08:17:43 +02:00
marcus 67275eb2f5 Fix #17: Fehler und Warnungen nicht mehr als INFO loggen
Verarbeitungsfehler (PreCheckFailed, AiFunctionalFailure) und
Retry-Entscheidungen (FAILED_RETRYABLE, FAILED_FINAL) werden nun auf
WARN-Level geloggt. EmptyList- und IncompleteConfiguration-Ergebnisse
des Modellabrufs sowie fehlende Quelldateien im Mini-Lauf ebenfalls.
Tests angepasst: Assertions prüfen jetzt das korrekte WARN-Level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:58:30 +02:00
marcus 955adc0c45 Fix #18: Leeres Datumsfeld wird als Fehler behandelt statt als fehlendes Datum
Die KI liefert manchmal "date": "" statt das Feld wegzulassen. Laut Spezifikation
ist date optional – fehlt es oder ist es leer, soll das aktuelle Datum als Fallback
verwendet werden.

Änderung in AiResponseValidator:
- Leere Strings (nach trim) werden identisch wie fehlende date-Felder behandelt
- Fallback auf aktuelles Datum über ClockPort mit DateSource.FALLBACK_CURRENT
- Validierungsfehler "date could not be parsed" wird nicht mehr geworfen

Neuer Test:
- validate_aiProvidesEmptyDateString_usesFallbackCurrentDate überprüft, dass
  "date": "" zum Fallback-Datum führt

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 17:42:39 +02:00
marcus e7f5590934 Fix #15: Log-Eintrag am Laufende liest lokale Zähler statt RunSummary
Synthetisierte Fehlzeilen (fehlende Quelldatei) werden nur im lokalen
failedCount gezählt, nicht in RunSummary. Der abschließende Log-Eintrag
verwendete bisher summary.failedCount() und zeigte daher fehlgeschlagen=0
obwohl die GUI-Anzeige korrekt war.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:25:00 +02:00
marcus c46294159c Fix #16: TitleCharacterRule um Punkt, Komma und Ampersand erweitern
- Erweitere TitleCharacterRule.isAllowed() um die Zeichen: . (Punkt), , (Komma), & (Ampersand)
- Passe JavaDoc-Kommentare auf Deutsch an
- Aktualisiere TitleCharacterRuleTest: ändere Punkt-Test von disallowed zu allowed
- Füge Tests für Komma und Ampersand hinzu
- Füge Tests hinzu, die Windows-Sonderzeichen (\ / : * ? " < > |) weiterhin als ungültig bestätigen
- Aktualisiere TargetFilenameBuildingServiceTest für den neuen Test-Fall
- Dokumentation: fachliche-anforderungen.md und CLAUDE.md aktualisiert

mvn clean verify erfolgreich bestanden

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 17:07:07 +02:00
marcus 1df541d0f9 Fix #15: Fehlende Quelldatei beim Mini-Lauf wird korrekt als fehlgeschlagen gezählt
appendSummary verwendete bisher summary.failedCount() aus dem RunSummary-Objekt
des echten Laufs, das von synthesizeMissingSourceFileRows() nachträglich hochgezählte
failedCount nicht kannte. Nun werden die lokalen Zähler (successCount, failedCount,
skippedCount) verwendet, die über onDocumentCompleted und die Synthese konsistent
gepflegt werden. Test ergänzt um Assertion auf '1 fehlgeschlagen' in der Zusammenfassung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:16:39 +02:00
marcus 09605ee495 Fix V2.8: selectedRows-Leerproblem und isRunning()-Inkonsistenz behoben
- markSelectedRowsAsResetPending() schützt selectedRows jetzt mit
  selectionSyncInProgress=true, sodass der TableView-SelectionModel-
  Listener die Selektion nicht löscht, wenn Zeilen ersetzt werden
- isRunning() und updateButtonStates() verwenden runningProperty.get()
  statt coordinator.isRunning() für konsistentes Verhalten zwischen
  Button-Zustand und Selektion
- Diagnose-LOG am Anfang von handleReprocessSelected() gibt isRunning()
  und selectedRows.size() aus (Laufend=false, Selektion>0 erwartet)
- Alle [TEMP-TRACE]-Logs entfernt aus GuiBatchRunCoordinator,
  SqliteUnitOfWorkAdapter, SqliteDocumentRecordRepositoryAdapter
  und DocumentProcessingCoordinator

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 15:35:57 +02:00
marcus 55088354ab Diagnoselogs und Test für DB-Reset-Verifikation (FAILED_FINAL)
- [TEMP-TRACE] INFO-Logs in SqliteDocumentRecordRepositoryAdapter:
  deleteByFingerprint() zeigt Fingerprint, jdbcUrl und rowsAffected;
  findByFingerprint() zeigt Fingerprint, jdbcUrl und Lookup-Ergebnis
- [TEMP-TRACE] Log in DocumentProcessingCoordinator.processDeferredOutcome()
  zeigt Fingerprint und Lookup-Ergebnis-Typ nach DB-Abfrage
- Bestehende [TEMP-TRACE] Logs in GuiBatchRunCoordinator und
  SqliteUnitOfWorkAdapter sind ebenfalls enthalten
- Neuer Test resetDocumentByFingerprint_deletesFailedFinalRecord_resultIsDocumentUnknown:
  legt FAILED_FINAL-Datensatz in echter SQLite-DB an, führt Reset aus
  und prüft, dass der Datensatz danach DocumentUnknown zurückliefert

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 15:00:22 +02:00
marcus 83f6d63c27 Review-Fix: Korrekte Fehlermeldung bei fehlgeschlagenem DB-Reset
Wenn startReprocessing() false zurückgibt, wurde bisher
ALREADY_RUNNING_HINT angezeigt – obwohl handleReprocessSelected()
isRunning() bereits vorab prüft. Das false bedeutet in diesem Kontext
einen Reset-Fehler, nicht einen laufenden Run.

Neu: REPROCESS_RESET_FAILED_HINT mit erklärender Meldung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:07:37 +02:00
marcus b41b4112c4 V2.8 Fix: „Erneut verarbeiten" setzt DB-Status vor Mini-Lauf zurück
Das Problem: Der „Erneut verarbeiten"-Button startete einen Mini-Lauf,
ohne den DB-Status der selektierten Dateien zurückzusetzen. Dateien mit
FAILED_FINAL-Status wurden daher vom Use Case übersprungen.

Die Lösung:
1. Neue Methode startReprocessing() in GuiBatchRunCoordinator, die
   resetPort.reset() SYNCHRON vor dem Mini-Lauf aufruft.
2. handleReprocessSelected() in GuiBatchRunTab nutzt jetzt
   startReprocessing() statt startMiniRun() direkt.
3. Test-Fix: noOpReset muss die Fingerprints in der erfolgreich-zurückgesetzt-
   Liste enthalten, damit successCount() > 0 ist.

Spec-Konformität:
- Reset erfolgt synchron vor dem Worker-Thread-Start
- Keine neue Architektur-Verletzung
- Hexagonale Architektur bleibt sauber (Port/Adapter)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 14:02:45 +02:00
marcus 9fd5bd5a52 V2.8: Selektive Wiederverarbeitung und Statusreset in der GUI
- Mehrfachauswahl mit CheckBox-Spalte und Master-Tri-State-Checkbox
- Gezielter Mini-Lauf über ausgewählte Einträge (unabhängig vom Status)
- Statusreset für ausgewählte Einträge (Stammsatz + Versuchshistorie)
- Fehlende Quelldatei im Mini-Lauf wird als FAILED_PERMANENT synthetisiert
- Identische Zieldatei wird als SUCCESS ohne erneute KI-Verarbeitung erkannt
- Weiche Stop-Semantik erhält zurückgesetzte Einträge unverändert
- Nicht-ausgewählte Einträge bleiben in allen Pfaden unberührt
- Buttons reagieren jetzt korrekt auf Auswahländerungen

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 12:04:22 +02:00
marcus f4a1bce9ae Spezifikation für V2.8 hinzugefügt 2026-04-23 10:14:54 +02:00
marcus 5d0e2c90bd Fix Issue #13: Warnschwelle für max.title.length auf 10–39 angehoben
Neue Warnschwellen: 10–39 Warnung (Absender benötigt 15–20 Zeichen),
40–99 unkritisch, 100–120 Warnung (verschlüsselte Volumes). Tests,
Validator-Implementierungen, Smoke-Tests und Docs konsistent angepasst.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 08:11:36 +02:00
marcus c61108fe1b Fix Issue #10: Prompt verschärfen – KI muss sich strikt an max.title.length halten
- Längenbegrenzung auf {MAX_TITLE_LENGTH} als harte, nicht verhandelbare Pflicht formuliert
- KI erhält explizite Anweisung zur eigenständigen Kürzung bei Bedarf
- Konkrete Kürzungsoptionen aufgelistet (Betreff kürzen, Details weglassen, Abkürzungen, optional Absender weglassen)
- Zusätzliche Beispiele zeigen Kürzungsstellen bei zu langen Titeln
- Garantie: KI liefert IMMER einen Titel der das Limit einhält

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 07:56:14 +02:00
marcus d1cffe8ef9 Konsolidiere Titelzeichen-Validierung in TitleCharacterRule
Doppelte private isAllowedTitleCharacters-Methoden in AiResponseValidator
und TargetFilenameBuildingService werden durch eine kanonische
TitleCharacterRule.isAllowed()-Methode ersetzt. Beide Services delegieren
jetzt dorthin statt eigene Kopien zu pflegen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 07:32:41 +02:00
marcus 2e6d0b1d6d Fix Bug #12 (2/2): Zweite Validierungsstelle in TargetFilenameBuildingService angepasst
- TargetFilenameBuildingService.isAllowedTitleCharacters() erlaubt jetzt Bindestrich (-)
- Fehlermeldung nach Windows-Cleaning aktualisiert
- JavaDoc aktualisiert
- Test TargetFilenameBuildingServiceTest.buildBaseFilename_titleWithHyphen angepasst
- Test DocumentProcessingCoordinatorTest angepasst (Bindestrich → Ausrufezeichen)
- Vollständiger Reactor-Build grün: mvn clean verify
2026-04-23 07:24:49 +02:00
marcus 34c8245ae9 Fix Bug #12: Bindestrich im Titel wird jetzt erlaubt
- Validierungsregel in AiResponseValidator erweitert um Bindestrich (-)
- Test angepasst: validate_titleWithHyphen jetzt als Valid-Case akzeptiert
- Dokumentation (fachliche Anforderungen, CLAUDE.md) aktualisiert
- mvn clean verify erfolgreich durchgelaufen
2026-04-23 07:02:06 +02:00
marcus c7f53416ca Ergaenze fehlende Tests fuer SKIPPED-Statusdurchleitung im GUI-Adapter
Fehlende Testabdeckung fuer den SKIPPED-Pfad: Der Coordinator-Test
verifiziert jetzt, dass SKIPPED-Events als row:SKIPPED:... dispatcht
werden und korrekt in der Zusammenfassung gezaehlt werden. Der
Smoke-Test prueft zusaetzlich, dass die ► Icon (nicht ✘) fuer
SKIPPED-Zeilen in der Ergebnistabelle erscheint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:13:33 +02:00
marcus 20a14b3c62 Behebe Darstellungsfehler der Statusicons und Stale-Summary-Bug
Statusicons (Bug 2): Emoji-Codepunkte werden durch BMP-Zeichen ersetzt
(✔ ⚠ ✘ ►), die in JavaFX auf Windows zuverlässig gerendert werden.
Die Statusspalte erhält eine farbige Cell-Factory (grün/orange/rot/grau).

Stale Summary (Bug 1): observerSummary wird zu Beginn jedes Laufs
zurückgesetzt, damit eine abgebrochene Vorgänger-Zusammenfassung
nicht als Ergebnis des neuen Laufs erscheint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:27:33 +02:00
marcus f4cfb5cbc0 Ergaenze zweiten GUI-Tab fuer Verarbeitungslauf mit Live-Fortschritt
- Fuehrt neuen Inbound-Adapter-Subpfad batchrun/ mit Tab, Koordinator,
  Launcher-Port und Ergebniszeilen-Model ein; der Batch-Lauf laeuft auf
  einem Hintergrund-Worker, UI-Updates ausschliesslich via FX-Dispatcher.
- Ergaenzt application.port.in um BatchRunProgressObserver,
  BatchRunCancellationToken, DocumentCompletionEvent/-Status und
  RunSummary; DefaultBatchRunProcessingUseCase und
  DocumentProcessingCoordinator melden Lauf-/Dokument-Ereignisse an den
  Beobachter und unterstuetzen Soft-Stop zwischen Kandidaten.
- Verdrahtet BootstrapRunner so, dass die GUI den vollstaendigen
  Headless-Pipelinepfad (Migration, Validierung, Schema-Init, Lock,
  Use-Case) mit Observer und Cancellation ausfuehrt; headless-Verhalten
  bleibt unveraendert.
- Editor-Workspace bettet den zweiten Tab ein, sperrt Tab 1 mit
  Hinweisbanner waehrend eines Laufs und fragt den Benutzer beim
  Schliessen waehrend eines laufenden Batches.
- Fuegt Tests fuer Observer-Wiring, Koordinator-Lebenszyklus und
  Tab-Smoke-Verhalten ein; aktualisiert die GUI-Bedienanleitung und
  docs/betrieb.md auf den neuen Tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:29:06 +02:00
marcus eacc205865 Behebe XML-Parsing-Fehler im Kommentar (doppelte Bindestriche in Kommentaren verboten)
XML-Kommentare dürfen keine Sequenz "--" enthalten. Der vorherige Kommentar
enthielt "--add-opens" was den Parser verwirrt hat.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-22 14:01:38 +02:00
marcus 566a7b97dd Prompt Anpassung 2026-04-22 13:58:36 +02:00
marcus d1fa989016 Entferne veralte --add-opens JavaFX-Module-Argumente aus Surefire
Java 21 mit modernem JavaFX (21.x) und Monocle benötigt die expliziten
--add-opens Argumente für javafx.graphics nicht mehr. Diese Argumente
verursachten "Unknown module" Warnungen beim Build, da die JavaFX-Module
in headless Tests nicht als benannte Module vorhanden sind.

Mit deren Entfernung ist der Build sauberer und alle Tests bestehen weiterhin.
Die verbleibende JavaFX-Warnung "Unsupported JavaFX configuration" ist
unvermeidlich und harmlos bei Monocle-Tests.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-22 13:58:09 +02:00
marcus 4875a1ed42 Fix doppelte API-Key-Meldung: originLabel zeigt nur noch ENV-Hinweis
Der API-Key-Herkunfts-Label (apiKeyOriginLabel) zeigte bisher sowohl
INFO-Befunde (Schlüssel kommt aus Umgebungsvariable) als auch
WARNING/ERROR-Befunde (Schlüssel fehlt) an. Da das fieldErrorLabel
direkt darunter dieselben WARNING/ERROR-Befunde bereits anzeigt,
erschien die „Kein API-Key"-Meldung zweimal im selben Bereich.

Lösung: refreshApiKeyOriginLabels() wertet nur noch INFO-Befunde aus.
WARNING/ERROR-Befunde für fehlende API-Keys werden ausschließlich vom
fieldErrorLabel angezeigt. STYLE_ORIGIN_MISSING entfernt.

Drei neue Smoke-Tests sichern das Verhalten ab:
- apiKeyAbsent_originLabelHidden
- apiKeyAbsent_fieldErrorLabelVisible
- apiKeyAbsent_noDuplicateMessageInPendingMessages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:42:18 +02:00
marcus 0f07947879 Fix OpenAI-Adapter: extrahiert choices[0].message.content zweistufig
Die OpenAI Chat Completions API liefert den eigentlichen KI-Inhalt als
escaped JSON-String in choices[0].message.content, nicht als direktes
JSON-Objekt. Der Adapter gab bisher den gesamten Envelope zurück, was
dazu führte, dass AiResponseParser das Pflichtfeld 'title' nicht fand.

Neues Verhalten: extractContentFromResponse() parst zunächst den äußeren
Envelope und gibt choices[0].message.content als AiRawResponse-Inhalt
weiter – analog zum AnthropicClaudeHttpAdapter. Bei fehlendem Inhalt
(leer, kein choices-Array) oder unparseablem Envelope wird eine
technische Failure (NO_CHOICE_CONTENT bzw. UNPARSEABLE_JSON) zurückgegeben.

Tests aktualisiert und drei neue Tests für den zweistufigen Parse-Pfad
sowie für Fehlerfälle ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:06:35 +02:00
marcus 8884d15e69 Ändere Standard-Default für max.text.characters von 5000 auf 1000
Der bisherige Standard-Default von 5000 Zeichen löste gemäß Spezifikation
sofort eine starke Warnung in der GUI aus (Schwellenwert: >3000).
Dies ist nicht benutzerfreundlich.

Der neue Standard-Default ist 1000 Zeichen (unkritisch laut Spec).
Das entspricht einer besseren Balance zwischen KI-Input-Größe und
Benutzerwarnung beim Start.

Änderungen:
- GuiConfigurationTemplateFactory: Standardvorlage auf 1000 geändert
- Alle *.properties-Beispieldateien aktualisiert
- Dokumentation in gui-bedienanleitung.md ergänzt
- Betroffene Tests angepasst (etwa 10 Testdateien)
- Alle 206 Tests bestehen nach der Änderung

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-22 12:44:09 +02:00
marcus 3e1f59fd12 Fix Windows-Konsolen-Encoding für Log-Ausgaben (UTF-8 vs. CP850)
- log4j2.xml: charset="UTF-8" explizit auf beiden PatternLayout-Elementen
  (Console + RollingFile), damit Log4j2 unabhängig vom JVM-Standard UTF-8
  schreibt
- Batch-Dateien: chcp 65001 vor dem EXE-Aufruf, damit die Windows-Konsole
  die UTF-8-Bytes korrekt anzeigt
- docs/betrieb.md: neuer Abschnitt "Konsolen-Encoding unter Windows" mit
  Hinweis auf chcp 65001 und -Dstdout.encoding=UTF-8 für den direkten
  Standalone-JAR-Start

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 12:13:50 +02:00
marcus 13e4922272 Meldungsbereich: Button linksbündig; leeren bei Validieren und Techn. Tests
- clearMessagesButton ist jetzt linksbündig (CENTER_LEFT statt CENTER_RIGHT)
- pendingMessages.clear() wird auch am Anfang von runValidationAction() und
  runTechnicalTestsAction() aufgerufen; jeder Durchlauf zeigt nur seine eigenen
  Befunde
- GuiValidateActionSmokeTest: Erwartung von 2 auf 1 Bestätigungsmeldung nach
  zwei Klicks angepasst (Replace- statt Akkumulierungsverhalten)
- Zwei neue Smoke-Tests: validationAction_clearsPreviousMessages und
  technicalTestsAction_clearsPreviousMessages
- Dokumentation in docs/gui-bedienanleitung.md ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:54:14 +02:00
marcus 1996f31f43 Meldungsbereich: leeren bei Neu/Öffnen und Button zum manuellen Leeren
- pendingMessages wird in applyEditorState() geleert, bevor die neue
  Konfiguration angezeigt wird (gilt für Neu und Öffnen)
- Neuer Button "Meldungen leeren" unterhalb des Meldungsbereichs;
  ruft clearMessages() auf, das pendingMessages leert und die Ansicht
  aktualisiert
- Dokumentation in docs/gui-bedienanleitung.md ergänzt
- Zwei neue Smoke-Tests: Neu löscht bisherige Meldungen,
  clearMessages() leert den Bereich vollständig

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:33:13 +02:00
marcus e07b460cdd Meldungen können nun in die Zwischenablage kopiert werden. 2026-04-22 11:23:44 +02:00
marcus 9ba32f1bb8 Bugfix Pfaderkennung 2026-04-22 10:21:02 +02:00
marcus 8286d0f0e5 Titellänge nun parametrisierbar 2026-04-22 09:53:03 +02:00
marcus 088fd85572 Anpassung der Testfälle 2026-04-21 20:34:01 +02:00
marcus 8be1848ba9 Fix: Verarbeitung von PROPOSAL_READY bis SUCCESS in einem Lauf; log4j-core im GUI-Test-Classpath
Der Dokument-Processing-Coordinator finalisiert jetzt unmittelbar nach dem
Persistieren des PROPOSAL_READY-Versuchs im selben Lauf zur Zielkopie und zu
SUCCESS. Die Invariante "neuester PROPOSAL_READY-Versuch ist die fuehrende
Quelle" bleibt gewahrt: Pro Lauf entstehen zwei Historieneintraege
(PROPOSAL_READY, dann SUCCESS). Bootstrap-E2E-Tests auf Single-Run-Semantik
angepasst; die "kein neuer KI-Aufruf bei vorhandenem PROPOSAL_READY"-Invariante
ist weiterhin im Application-Unit-Test abgedeckt.

Zusaetzlich log4j-core als Test-Scope-Abhaengigkeit im GUI-Modul ergaenzt,
damit die "Log4j2 could not find a logging implementation"-Warnung im
Testlauf nicht mehr erscheint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:26:21 +02:00
marcus aaedc2d713 Neues Modul pdf-umbenenner-packaging und zugehörige Dokumentation
- Parent-POM bindet das neue Modul ein und pflegt die jpackage-Plugin-Version
- pdf-umbenenner-packaging enthält jpackage-Inputs: Launcher-Batches, Icon,
  Beispiel-Properties und Icon-README
- CLAUDE.md und docs/betrieb.md ergänzen die MSI-/Packaging-Hinweise
- Arbeitspaket-Dokumente M14 und M15 neu aufgenommen
2026-04-21 16:11:10 +02:00
marcus ada7e203e3 GUI-Bugfixes: Defaults beim Start, kopierbare Meldungen mit Zeitstempel, Befundauflistung, Modell-ComboBox links, effektiver API-Key für Modellabruf
- Blank-Startzustand zeigt jetzt die Standardvorlage (wie nach "Neu"), neue Factory createEmptyStartState für Tests
- Meldungsbereich ist per Kontextmenü bzw. Strg+C kopierbar
- Jede Meldung trägt ein führendes [HH:mm:ss]-Präfix
- Validieren- und Tests-Aktionen akkumulieren Meldungen, automatische Validierung ersetzt still ihre Einträge
- Validieren-Meldung listet alle konkreten Befunde einzeln auf
- Modell-ComboBox und manuelles Modellfeld sind linksbündig
- ApiKeyResolutionPort liefert jetzt den effektiven API-Schlüsselwert (Default + Env-Adapter-Override), so dass der Modellliste-Test in den technischen Tests nicht mehr "API-Schlüssel fehlt" meldet, obwohl er gesetzt ist
2026-04-21 16:04:15 +02:00
van Elst, Marcus 6babdd226e Source code cleanup 2026-04-21 10:38:16 +02:00
van Elst, Marcus 202088d1d3 Removed unused imports 2026-04-21 10:31:47 +02:00
marcus 523774707b PIT auf Domain und Application beschränken 2026-04-21 06:06:45 +02:00
marcus 016da8318d M13 vollständig abgeschlossen: V2.0-Freigabe (AP-001 bis AP-009)
- AP-001: Betriebs- und Startdokumentation für GUI und headless
  konsolidiert (betrieb.md, README.md)
- AP-002: Endbenutzer-Bedienanleitung gui-bedienanleitung.md angelegt
  (deskriptiv, 13 Kapitel, deutsch, Windows-Hinweise)
- AP-003: Konfigurationsbeispiele docs/examples/application.properties
  und docs/examples/prompt.txt konsolidiert, konsistent mit Standardvorlage
- AP-004: Regressionstests für headless Abwärtskompatibilität
  (JAR-Smoke-IT mit --config-Varianten und JavaFX-Freiheit)
- AP-005: GUI-Smoke-Tests für V2.0-Kernumfang vervollständigt
  (Startup-Notice-Sichtbarkeit im Header)
- AP-006: Build- und Packaging-Dokumentation im Abschnitt
  "Build und Packaging" in betrieb.md, README-Artefaktnamen korrigiert
- AP-007: Integrierte Gesamtprüfung durchgeführt, V2.0-Abschnitt in
  befundliste.md — keine Release-Blocker, zwei nicht blockierende
  Restpunkte (R1 ByteBuddy-Warning, R2 fehlender visueller GUI-Render-Test)
- AP-008: entfiel (keine Release-Blocker zu beheben)
- AP-009: Finale Gesamtprüfung, Freigabedokument docs/freigabe-v2_0.md
  mit Git-HEAD, Build-/Test-Ergebnissen, Freigabeaussage. Ein während
  der Stichprobe entdeckter Doku-Defekt (R3: API-Key-Legacy-Variable)
  wurde unmittelbar in gui-bedienanleitung.md korrigiert.

V2.0 ist freigabefähig. 1.403 Tests grün, 0 Failures, 0 Errors.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-20 22:50:51 +02:00
marcus 1bb7a42735 M12 vollständig abgeschlossen (AP-001 bis AP-008)
- AP-001: Prüf- und Korrektur-Kernobjekte (CheckpointId, CheckpointResult
  sealed interface, TechnicalTestReport mit Correction-Plan-Ableitung,
  CorrectionSuggestion sealed interface, PathCheckPort, ResourceCreationPort)
- AP-002: Aktion "Validieren" als explizite, nicht schreibende Gesamtprüfung
  des aktuellen Editorzustands
- AP-003: Provider-nahe technische Prüflogik für Endpoint, API-Key,
  Modellliste und Modellplausibilität — wiederverwendet den bestehenden
  Modellabruf-Port, kein zweiter HTTP-Pfad
- AP-004: Windows-Pfadprüfung mit ausdrücklicher Unterstützung gemappter
  Laufwerksbuchstaben (FilesystemPathCheckAdapter)
- AP-005: Aktion "Technische Tests ausführen" als vollständiger Gesamttest
  ohne Frühabbruch, Orchestrator sammelt Befunde aller Prüfblöcke
- AP-006: Schreibende Korrekturhilfen mit gesammeltem Bestätigungsdialog,
  CorrectionExecutionService, FilesystemResourceCreationAdapter
- AP-007: Automatische deutsche Standard-Prompt-Datei-Erzeugung,
  Default-Pfad neben der .properties-Datei, klare Fehlermeldung bei
  nicht beschreibbarem Zielpfad
- AP-008: Regressionstests für Gesamttest ohne Frühabbruch, ungespeicherte
  Editorzustände, Korrekturdialog, Prompt-Erzeugung, Windows-Pfade

Hexagonale Architektur durchgehend eingehalten, Domain und Application
bleiben infrastrukturfrei. Threadingmodell konsequent umgesetzt.
Naming-Regel und JavaDoc-Standard eingehalten.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-20 21:57:06 +02:00
marcus aa067a3165 M11 vollständig abgeschlossen (AP-001 bis AP-007)
- AP-001: Kernobjekte und Port-Verträge (ModelCatalog-Port, sealed
  Result-Typen, ApiKeyOrigin, GUI-Modell- und Meldungs-Records)
- AP-002: Provider-ComboBox, exklusiver Providerbereich,
  zustandsbewahrender Providerwechsel
- AP-003: HTTP-Adapter für Modellabruf (Claude, OpenAI-kompatibel)
  mit vollständigem Error-Mapping und Dispatcher im Bootstrap
- AP-004: Automatischer Modellabruf bei Providerwechsel, Aktion
  "Modelle neu laden", Umschaltung zwischen Modell-ComboBox und
  Modell-Textfeld, Worker-Thread-Kapselung
- AP-005: Automatische Editorvalidierung (Pflichtfelder,
  Warnschwellen max.text.characters, Plausibilitätshinweise
  max.pages, API-Key-Herkunftsauflösung mit Vorrangregel)
- AP-006: Zentraler Meldungsbereich mit vier Severity-Stufen,
  feldnahe rote Fehlermeldungen, API-Key-Herkunftsanzeige
- AP-007: Integrations- und Regressionstests, Timeout-Mapping-Tests,
  Replace-Semantik für wiederholte Modellabruf-Meldungen

Hexagonale Architektur eingehalten, Application- und Domain-Schicht
bleiben infrastrukturfrei. Threadingmodell konsequent umgesetzt.
Naming-Regel und JavaDoc-Standard durchgängig beachtet.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-20 20:31:15 +02:00
364 changed files with 48242 additions and 1926 deletions
+3
View File
@@ -3,6 +3,8 @@
# ========================================================= # =========================================================
**/target/ **/target/
dependency-reduced-pom.xml dependency-reduced-pom.xml
# Generierte Flat-POM-Dateien des flatten-maven-plugin (CI-friendly Versioning)
**/.flattened-pom.xml
# ========================================================= # =========================================================
# Eclipse / IDE # Eclipse / IDE
@@ -75,3 +77,4 @@ replay_pid*
/run-milestone.ps1 /run-milestone.ps1
/run-v11.ps1 /run-v11.ps1
.m2repo .m2repo
/start-headless.bat
+31 -8
View File
@@ -11,9 +11,18 @@ Ab V2.0 wird die Anwendung um eine **lokale JavaFX-Desktop-GUI** erweitert. Die
@docs/specs/meilensteine-v2_0.md @docs/specs/meilensteine-v2_0.md
Für die Umsetzung ist zusätzlich immer das aktuell aktive Arbeitspaket unter `docs/workpackages/` maßgeblich. Für die Umsetzung ist zusätzlich immer das aktuell aktive Arbeitspaket unter `docs/workpackages/` maßgeblich.
Dateinamensschema: `M9 - Arbeitspakete.md`, `M10 - Arbeitspakete.md`, … `M13 - Arbeitspakete.md` Dateinamensschema: `M9 - Arbeitspakete.md`, `M10 - Arbeitspakete.md`, … `M13 - Arbeitspakete.md`, `M14_-_Arbeitspakete.md`, `M15_-_Arbeitspakete.md`.
Nicht raten, wenn Dokumente fehlen, unklar sind oder sich widersprechen. Nicht raten, wenn Dokumente fehlen, unklar sind oder sich widersprechen.
## Modulare Architektur-Übersichten
Detailwissen über Pakete, Schlüsselklassen, Ports und Bootstrap-Verdrahtung ist in drei modularen Übersichtsdokumenten unter `docs/architecture/` ausgelagert. Wer in einem bestimmten Modul arbeitet, liest diese Datei zusätzlich zu CLAUDE.md:
- `docs/architecture/domain-overview.md` `pdf-umbenenner-domain` und `pdf-umbenenner-application`: Domänenmodell, Inbound- und Outbound-Ports, Application-Services.
- `docs/architecture/gui-overview.md` `pdf-umbenenner-adapter-in-gui`: Workspace-/Tab-Struktur, View-Modelle, GUI-interne Ports, JavaFX-Threading-Modell.
- `docs/architecture/adapter-overview.md` `pdf-umbenenner-adapter-out`, `pdf-umbenenner-adapter-in-cli`, `pdf-umbenenner-bootstrap`: konkrete Outbound-Adapter, CLI-Einstiegspunkt, Verdrahtungslogik und Provider-Auswahl.
Für Arbeit ausschließlich in einem dieser Bereiche genügt CLAUDE.md plus die jeweils passende Übersichtsdatei.
## Priorisierung der Regeln ## Priorisierung der Regeln
Die Dokumente haben folgende feste Bedeutung: Die Dokumente haben folgende feste Bedeutung:
@@ -49,7 +58,8 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
- kein Applikationsserver - kein Applikationsserver
- keine Dauerlauf-Anwendung - keine Dauerlauf-Anwendung
- kein interner Scheduler - 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 - Log4j2 für Logging
- SQLite als lokaler Persistenzspeicher - SQLite als lokaler Persistenzspeicher
- JavaFX wird mit dem JAR ausgeliefert (kein separates JavaFX-Setup) - JavaFX wird mit dem JAR ausgeliefert (kein separates JavaFX-Setup)
@@ -120,9 +130,9 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
## Globale fachliche Leitplanken ## Globale fachliche Leitplanken
- Zielformat: `YYYY-MM-DD - Titel.pdf` - Zielformat: `YYYY-MM-DD - Titel.pdf`
- Bei Namenskollisionen: `YYYY-MM-DD - Titel(1).pdf`, `YYYY-MM-DD - Titel(2).pdf`, ... - Bei Namenskollisionen: `YYYY-MM-DD - Titel(1).pdf`, `YYYY-MM-DD - Titel(2).pdf`, ...
- Die **20 Zeichen** gelten nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit - Die **konfigurierte maximale Titellänge** gilt nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit
- Das Dubletten-Suffix wird unmittelbar vor `.pdf` angehängt - Das Dubletten-Suffix wird unmittelbar vor `.pdf` angehängt
- Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen - Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen, Bindestrichen, Punkten, Kommas und Ampersands
- Eigennamen bleiben unverändert - Eigennamen bleiben unverändert
- Datumsermittlung mit Priorität aus den fachlichen Anforderungen; wenn kein belastbares Datum eindeutig ableitbar ist, ist das **aktuelle Datum** als Fallback erlaubt - Datumsermittlung mit Priorität aus den fachlichen Anforderungen; wenn kein belastbares Datum eindeutig ableitbar ist, ist das **aktuelle Datum** als Fallback erlaubt
- Mehrdeutige Dokumente liefern **kein** unsicheres Ergebnis, sondern einen Fehler - Mehrdeutige Dokumente liefern **kein** unsicheres Ergebnis, sondern einen Fehler
@@ -135,9 +145,13 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
## Aktiver Implementierungsstand ## Aktiver Implementierungsstand
V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben. V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben.
Der aktive Entwicklungsstand ist **V2.0**. Ziel ist der Ausbau um eine lokale JavaFX-Desktop-GUI als neuen Standardstart, ohne die bestehende Architektur, das Standalone-JAR-Betriebsmodell oder den headless Scheduler-Betrieb aufzugeben. Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technische Tests) ist abgeschlossen.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt in V2.0 unverändert. **V2.9 ist abgeschlossen.** Der Tab „Verarbeitungslauf" wurde erweitert um eine integrierte PDF-Vorschau (Lazy-Rendering direkt über PDFBox, In-Memory-Cache, Seitennavigation) sowie einen editierbaren Dateiname-Bereich mit Live-Validierung, Dirty-State-Dialog und atomarer Dateisystem-/DB-Transaktion inklusive Rollback und Fingerprint-basierter Konfliktauflösung. Die zugehörigen neuen Ports, Use Cases und Adapter sind in den modularen Architektur-Übersichten beschrieben.
Verhaltensänderungen seit V2.9: Die GUI startet maximiert, und die zuletzt geladene Konfigurationsdatei wird beim Start automatisch wieder geladen; existiert sie nicht mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert.
## Statussemantik ## Statussemantik
@@ -226,6 +240,13 @@ Bestehende Kommentare mit solchen Bezeichnern, die durch eigene Änderungen ber
- Keine stillen Änderungen am bestehenden headless Batch-Betrieb - Keine stillen Änderungen am bestehenden headless Batch-Betrieb
- GUI-Code darf den headless Pfad nicht unnötig früh initialisieren - GUI-Code darf den headless Pfad nicht unnötig früh initialisieren
## Commit und Push nach jeder Implementierung
Nach jeder Implementierung oder Dateiänderung wird ein Commit auf `main` erstellt und gepusht:
1. Geänderte Dateien stagen und committen
2. `git push origin main` ausführen
3. Schlägt der Push mit einem AUTH-Fehler fehl: 1 Sekunde warten, dann genau **einen** weiteren Versuch unternehmen
4. Schlägt auch der zweite Versuch fehl: Fehler benennen, keinen weiteren automatischen Retry
## Definition of Done pro Arbeitspaket ## Definition of Done pro Arbeitspaket
Ein Arbeitspaket ist erst fertig, wenn: Ein Arbeitspaket ist erst fertig, wenn:
- der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist - der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist
@@ -253,6 +274,8 @@ Ein Arbeitspaket ist erst fertig, wenn:
- Nach Änderungen den kleinsten sinnvollen Build-/Test-Umfang ausführen - Nach Änderungen den kleinsten sinnvollen Build-/Test-Umfang ausführen
- Build-Validierung vom Parent-Root (Beispiel für vollständigen Reactor-Build ab V2.0): - Build-Validierung vom Parent-Root (Beispiel für vollständigen Reactor-Build ab V2.0):
`.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make` `.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make`
- MSI-Build (nur lokal auf der Entwicklungsmaschine, WiX Toolset 3.x im PATH erforderlich):
`.\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests`
- Schlägt der Build fehl: Fehler beheben, erneut bauen, erst dann weiter - Schlägt der Build fehl: Fehler beheben, erneut bauen, erst dann weiter
- Vor Abschluss sicherstellen, dass der relevante Maven-Reactor-Stand fehlerfrei ist - Vor Abschluss sicherstellen, dass der relevante Maven-Reactor-Stand fehlerfrei ist
- Fehler nicht kaschieren; Ursachen sauber beheben oder offen benennen - Fehler nicht kaschieren; Ursachen sauber beheben oder offen benennen
@@ -273,6 +296,7 @@ Verbindlich zweckmäßige Parameter:
- `max.retries.transient` max. historisierte transiente Fehlversuche pro Fingerprint (**Integer >= 1**, `0` ist ungültig) - `max.retries.transient` max. historisierte transiente Fehlversuche pro Fingerprint (**Integer >= 1**, `0` ist ungültig)
- `max.pages` Seitenlimit - `max.pages` Seitenlimit
- `max.text.characters` maximale Zeichenzahl für KI-Eingabe - `max.text.characters` maximale Zeichenzahl für KI-Eingabe
- `max.title.length` maximale Länge des Basistitels in Zeichen (gültiger Bereich 10..120, Default 60)
- `prompt.template.file` externe Prompt-Datei - `prompt.template.file` externe Prompt-Datei
- `log.ai.sensitive` sensible KI-Logausgabe freischalten (Boolean, Default: `false`) - `log.ai.sensitive` sensible KI-Logausgabe freischalten (Boolean, Default: `false`)
- `runtime.lock.file` Lock-Datei (optional) - `runtime.lock.file` Lock-Datei (optional)
@@ -306,11 +330,10 @@ Verbindlicher Ablauf:
6. Erst danach den normalen Lauf fortsetzen 6. Erst danach den normalen Lauf fortsetzen
## Nicht-Ziele / Verbote ## Nicht-Ziele / Verbote
- kein manueller Verarbeitungslauf aus der GUI (erst V2.1+) - kein manueller Verarbeitungslauf aus der GUI (kein vollständiger Lauf; Bearbeitungen nach Lauf sind zulässig)
- kein DB-/Historien-Tab in der GUI (erst V2.x+) - kein DB-/Historien-Tab in der GUI (erst V2.x+)
- kein Kosten-Tracking (erst V2.x+) - kein Kosten-Tracking (erst V2.x+)
- kein echter Mini-KI-Testaufruf mit fachlicher Antwortauswertung - kein echter Mini-KI-Testaufruf mit fachlicher Antwortauswertung
- keine EXE, kein Installer
- kein Web-UI - kein Web-UI
- keine REST-API zur Bedienung - keine REST-API zur Bedienung
- keine OCR innerhalb der Java-Anwendung - keine OCR innerhalb der Java-Anwendung
Vendored
+184
View File
@@ -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
+48 -11
View File
@@ -4,14 +4,23 @@ Ein lokal gestartetes Java-Programm zur KI-gestützten Umbenennung bereits OCR-v
Die Anwendung liest PDF-Dateien aus einem konfigurierbaren Quellordner, extrahiert den Text, ermittelt daraus per KI einen normierten Dateinamen und legt **eine Kopie** im Zielordner ab. Die Quelldateien bleiben unverändert. Die Anwendung liest PDF-Dateien aus einem konfigurierbaren Quellordner, extrahiert den Text, ermittelt daraus per KI einen normierten Dateinamen und legt **eine Kopie** im Zielordner ab. Die Quelldateien bleiben unverändert.
> **V2.9:** Die Anwendung enthält eine lokale JavaFX-Desktop-GUI als Standardstart.
> Die GUI dient der Konfiguration, Validierung, technischen Diagnose und der Ausführung von Verarbeitungsläufen.
> Der Tab „Verarbeitungslauf" enthält eine integrierte PDF-Vorschau und einen editierbaren Dateiname-Bereich.
> Die GUI startet maximiert und lädt beim Start automatisch die zuletzt verwendete Konfigurationsdatei.
> Der headless Batch-Betrieb bleibt über `--headless` vollständig erhalten.
> Details zum Betrieb: [`docs/betrieb.md`](docs/betrieb.md)
## Zielbild ## Zielbild
Der PDF-Umbenenner ist bewusst als schlanke Batch-Anwendung ausgelegt: Der PDF-Umbenenner ist als schlanke, lokal gestartete Anwendung ausgelegt:
- **Java 21** - **Java 21**
- **Maven Multi-Module** - **Maven Multi-Module**
- **ausführbares Standalone-JAR** - **ausführbares Standalone-JAR** (ein gemeinsames JAR für GUI und headless)
- **lokaler Start**, z. B. über den **Windows Task Scheduler** - **GUI-Standardstart** ab V2.0 (JavaFX-Desktop, offiziell Windows)
- **headless Betrieb** über `--headless`, z. B. für den **Windows Task Scheduler**
- **`--config <pfad>`** für GUI und headless
- **kein Webserver** - **kein Webserver**
- **kein Applikationsserver** - **kein Applikationsserver**
- **keine Dauerlauf-Anwendung** - **keine Dauerlauf-Anwendung**
@@ -52,8 +61,8 @@ YYYY-MM-DD - Titel(2).pdf
Wichtige Regeln: Wichtige Regeln:
- die **20 Zeichen** beziehen sich nur auf den **Basistitel** - die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel**
- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen - das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge
- Titel werden auf **Deutsch** erzeugt - Titel werden auf **Deutsch** erzeugt
- Eigennamen bleiben unverändert - Eigennamen bleiben unverändert
- Quelldateien werden **nie** überschrieben, verschoben oder verändert - Quelldateien werden **nie** überschrieben, verschoben oder verändert
@@ -86,6 +95,7 @@ Das Projekt ist strikt nach **Ports and Adapters / Hexagonal Architecture** aufg
- `pdf-umbenenner-domain` - `pdf-umbenenner-domain`
- `pdf-umbenenner-application` - `pdf-umbenenner-application`
- `pdf-umbenenner-adapter-in-cli` - `pdf-umbenenner-adapter-in-cli`
- `pdf-umbenenner-adapter-in-gui`
- `pdf-umbenenner-adapter-out` - `pdf-umbenenner-adapter-out`
- `pdf-umbenenner-bootstrap` - `pdf-umbenenner-bootstrap`
@@ -110,6 +120,7 @@ Typische Bereiche sind:
- Timeout - Timeout
- Seitenlimit - Seitenlimit
- Textlimit für KI-Aufrufe - Textlimit für KI-Aufrufe
- maximale Titellänge (`max.title.length`, Default 60, Bereich 10..120)
- Prompt-Datei - Prompt-Datei
- Logging - Logging
@@ -135,13 +146,30 @@ Unter Windows:
## Start ## Start
Das ausführbare Artefakt wird im Bootstrap-Modul erzeugt. Der Start erfolgt als normales Java-Programm: Das ausführbare Artefakt wird im Bootstrap-Modul erzeugt.
**GUI-Standardstart** (öffnet die JavaFX-Desktop-Oberfläche):
```bash ```bash
java -jar <bootstrap-jar>.jar java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
``` ```
Die konkrete JAR-Datei hängt vom aktuellen Build-Stand ab. **headless Betrieb** (Batch-/Scheduler-Start ohne GUI):
```bash
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless
```
**Explizite Konfigurationsdatei** (für GUI und headless):
```bash
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --config C:\Pfad\zur\config.properties
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless --config C:\Pfad\zur\config.properties
```
Das JAR ist das einzige Distributionsartefakt und enthält JavaFX für den GUI-Start bereits
integriert. Ausführliche Build-, Packaging- und Starthinweise sowie Informationen zur JavaFX-
Integration und zum headless Betrieb befinden sich in [`docs/betrieb.md`](docs/betrieb.md).
## Logging, Status und Nachvollziehbarkeit ## Logging, Status und Nachvollziehbarkeit
@@ -160,7 +188,10 @@ Die maßgeblichen Dokumente sind:
- `CLAUDE.md` - `CLAUDE.md`
- `docs/specs/technik-und-architektur.md` - `docs/specs/technik-und-architektur.md`
- `docs/specs/fachliche-anforderungen.md` - `docs/specs/fachliche-anforderungen.md`
- `docs/specs/meilensteine.md` - `docs/specs/meilensteine-v2_0.md`
- `docs/betrieb.md`
- `docs/gui-bedienanleitung.md`
- `docs/freigabe-v2_0.md`
- `docs/workpackages/...` - `docs/workpackages/...`
Empfohlene Leserichtung: Empfohlene Leserichtung:
@@ -169,7 +200,9 @@ Empfohlene Leserichtung:
2. technische Zielarchitektur 2. technische Zielarchitektur
3. fachliche Anforderungen 3. fachliche Anforderungen
4. Meilensteine 4. Meilensteine
5. aktives Arbeitspaket 5. `docs/betrieb.md` für Betriebs- und Startdetails
6. `docs/gui-bedienanleitung.md` für die GUI-Bedienung
7. aktives Arbeitspaket
## Entwicklungsleitplanken ## Entwicklungsleitplanken
@@ -186,9 +219,13 @@ Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der a
- Konfiguration und Startvalidierung - Konfiguration und Startvalidierung
- Quellordner-Scan und PDF-Textauslese - Quellordner-Scan und PDF-Textauslese
- Fingerprint, SQLite-Persistenz und Idempotenz - Fingerprint, SQLite-Persistenz und Idempotenz
- KI-Integration für Benennungsvorschläge - KI-Integration für Benennungsvorschläge (OpenAI-kompatibel und Anthropic Claude)
- Dateinamensbildung und Zielkopie - Dateinamensbildung und Zielkopie
- Retry-Logik, Logging und betriebliche Robustheit - Retry-Logik, Logging und betriebliche Robustheit
- JavaFX-Desktop-GUI als Standardstart (Konfigurationseditor, Validierung, technische Tests)
- Tab „Verarbeitungslauf" mit integrierter PDF-Vorschau pro Zeile und editierbarem Dateiname-Bereich
- Atomare Dateisystem- und Datenbankoperationen für manuelle Umbenennungen mit Konfliktauflösung
- headless Batch-Betrieb über `--headless` (rückwärtskompatibel zu V1.x)
## Lizenz / Nutzung ## Lizenz / Nutzung
+4 -1
View File
@@ -26,7 +26,10 @@ max.retries.transient=3
max.pages=10 max.pages=10
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird. # Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
max.text.characters=5000 max.text.characters=1000
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
max.title.length=60
# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator # Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator
# in der Versuchshistorie. # in der Versuchshistorie.
@@ -13,6 +13,8 @@ sqlite.file=./work/test/pdf-umbenenner-test.db
max.retries.transient=1 max.retries.transient=1
max.pages=5 max.pages=5
max.text.characters=2000 max.text.characters=2000
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
max.title.length=60
prompt.template.file=./config/prompts/template.txt prompt.template.file=./config/prompts/template.txt
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+38 -8
View File
@@ -2,9 +2,35 @@ Du bist ein Assistent zur automatischen Benennung gescannter PDF-Dokumente.
Analysiere den folgenden Dokumenttext und ermittle: Analysiere den folgenden Dokumenttext und ermittle:
1. Einen inhaltlich passenden deutschen Titel (maximal 20 Zeichen, nur Buchstaben und Leerzeichen, keine Abkürzungen, keine generischen Bezeichnungen wie "Dokument", "Datei", "Scan" oder "PDF") 1. Einen inhaltlich passenden deutschen Titel nach dem Schema: {Absender} {Betreff_gekürzt}
2. Das relevanteste Datum des Dokuments 2. Das relevanteste Datum des Dokuments
Titelschema verbindlich:
- Erster Teil: Absender (Person, Firma, Behörde, Institution) so wie im Dokument genannt, Abkürzungen wie GmbH, AG, KfW, Kfz sind erlaubt
- Zweiter Teil: Betreff oder Kernaussage des Dokuments, so kurz wie möglich bevorzugt aus einer vorhandenen Betreffzeile, sonst aus dem Dokumentinhalt abgeleitet
- Beide Teile durch ein Leerzeichen getrennt, kein Sonderzeichen außer Bindestrich und Leerzeichen
- **Maximal {MAX_TITLE_LENGTH} Zeichen gesamt diese Grenze ist nicht verhandelbar und MUSS eingehalten werden**
- Keine generischen Begriffe wie "Dokument", "Datei", "Scan", "PDF", "Schreiben", "Brief"
- Titel auf Deutsch formulieren
WICHTIG Längenbegrenzung ist deine Verantwortung:
Wenn ein idealer Titel länger als {MAX_TITLE_LENGTH} Zeichen wäre, darfst und musst du ihn selbst kürzen. Optionen:
- Betreff verkürzen (z.B. "Steuerbescheid 2024" statt "Einkommensteuerbescheid 2024")
- Unwesentliche Details weglassen
- Absender mit Standard-Abkürzung darstellen
- Absender weglassen und nur Betreff nutzen, falls sinnvoll
Liefere IMMER einen Titel, der das Zeichenlimit einhält. Niemals einen, der es überschreitet.
Beispiele für gute Titel:
- Stadtwerke Bochum Grundbesitzabgaben 2025
- Allianz Versicherung Kfz-Nachtrag Polo
- Finanzamt Bochum Steuerbescheid 2024
- KfW Förderbescheid Energieeffizienz
Beispiele für Kürzung bei Längenlimit:
- zu lang: "Versicherungsgesellschaft Allianz Versicherung AG Kfz-Versicherungsnachtrag Volkswagen Polo" → gekürzt: "Allianz Kfz-Nachtrag Polo"
- zu lang: "Bundesfinanzbehörde Finanzamt Bochum Bescheid zur Einkommensteuer Veranlagung" → gekürzt: "Finanzamt Bochum Steuerbescheid"
Datumsermittlung nach Priorität: Datumsermittlung nach Priorität:
- Rechnungsdatum - Rechnungsdatum
- Dokumentdatum - Dokumentdatum
@@ -12,11 +38,15 @@ Datumsermittlung nach Priorität:
- Schreibdatum oder Ende eines Leistungszeitraums - Schreibdatum oder Ende eines Leistungszeitraums
- Kein Datum angeben, wenn kein belastbares Datum eindeutig ableitbar ist - Kein Datum angeben, wenn kein belastbares Datum eindeutig ableitbar ist
Titelregeln:
- Titel auf Deutsch formulieren
- Eigennamen (Personen, Firmen, Orte) unverändert übernehmen
- Maximal 20 Zeichen (nur der Basistitel, ohne Datumspräfix)
- Keine Sonderzeichen außer Leerzeichen
- Eindeutig und verständlich, nicht generisch
Wenn das Dokument nicht eindeutig interpretierbar ist, beschreibe dies im Reasoning. Wenn das Dokument nicht eindeutig interpretierbar ist, beschreibe dies im Reasoning.
**Ausgabeformat: Ausschließlich reines JSON-Objekt**
Antworte nur mit einem JSON-Objekt nach folgendem Schema:
- Keine Präambel, keine Erklärungen, keine Markdown-Codeblöcke
- `title` (erforderlich): Der ermittelte deutsche Titel nach obigem Schema
- `reasoning` (erforderlich): Absender und Betreff in je einem Satz begründen
- `date` (optional): Das ermittelte Datum im Format YYYY-MM-DD; auslassen, falls kein belastbares Datum ableitbar ist
Beispiel:
{"title":"Stadtwerke Bochum Grundbesitzabgaben 2025","reasoning":"Absender ist Stadtwerke Bochum laut Briefkopf. Betreff ist die Jahresabrechnung der Grundbesitzabgaben 2025.","date":"2025-03-15"}
+356
View File
@@ -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`*
+193
View File
@@ -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.
+209
View File
@@ -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`
+180
View File
@@ -173,3 +173,183 @@ Spezifikationen (technik-und-architektur.md, fachliche-anforderungen.md, CLAUDE.
vollständig umgesetzt und durch automatisierte Tests abgesichert. Der Maven-Build ist fehlerfrei. vollständig umgesetzt und durch automatisierte Tests abgesichert. Der Maven-Build ist fehlerfrei.
Die CLAUDE.md-Naming-Convention-Regel (kein M1M8, kein AP-xxx im Produktions- oder Testcode) Die CLAUDE.md-Naming-Convention-Regel (kein M1M8, kein AP-xxx im Produktions- oder Testcode)
ist vollständig eingehalten. Keine bekannten spezifikationsrelevanten Blocker sind offen. ist vollständig eingehalten. Keine bekannten spezifikationsrelevanten Blocker sind offen.
---
# V2.0-Gesamtstand Integrierte Prüfung (Stand 2026-04-20)
**Prüfgrundlage:** Vollständiger Maven-Reactor-Build mit allen Tests (clean verify, `-DskipPitest=true`),
Code-Review gegen die verbindliche Spec-Trias (technik-und-architektur.md, fachliche-anforderungen.md,
CLAUDE.md), Sichtprüfung Dokumentation und Konfigurationsbeispiele.
---
## Build- und Testergebnisse
**Ausgeführtes Kommando:**
```
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make -DskipPitest=true
```
**Gesamtergebnis: BUILD SUCCESS**
**Gesamtlaufzeit:** 01:18 min
**Datum:** 2026-04-20
| Modul | Tests | Failures | Errors | Skipped |
|---|---|---|---|---|
| `pdf-umbenenner-domain` | 227 | 0 | 0 | 0 |
| `pdf-umbenenner-application` | 455 | 0 | 0 | 0 |
| `pdf-umbenenner-adapter-in-cli` | 8 | 0 | 0 | 0 |
| `pdf-umbenenner-adapter-in-gui` | 190 | 0 | 0 | 0 |
| `pdf-umbenenner-adapter-out` | 371 | 0 | 0 | 0 |
| `pdf-umbenenner-bootstrap` | 147 | 0 | 0 | 0 |
| **Gesamt** | **1.398** | **0** | **0** | **0** |
Alle Module bauen erfolgreich. Alle Tests bestehen. Das ausführbare Shade-JAR wird erzeugt und
enthält JavaFX (Win-Classifier), alle Module, PDFBox, SQLite-JDBC und Log4j2.
**Warnungen im Build:**
- `WARNING: A Java agent has been loaded dynamically (byte-buddy-agent)` dies ist ein bekannter
Mockito/ByteBuddy-Hinweis, der in Tests auftritt. Kein funktionaler Defekt. Tritt seit V1.1 auf
und gilt als akzeptiert.
---
## Prüfpunkte gegen die V2.0-Spezifikation (120)
| Nr. | Prüfpunkt | Status | Begründung |
|---|---|---|---|
| 1 | GUI ist Standardstart | **erfüllt** | `PdfUmbenennerApplication.main()``BootstrapRunner.run()``StartupMode.GUI` als Default; `--headless` erforderlich für Batch-Pfad. Korrekt implementiert und getestet (`BootstrapRunnerStartupDispatchTest`). |
| 2 | `--headless` erhalten, `--config` für beide Startarten | **erfüllt** | `CliArgumentParser` parst beide Optionen korrekt. `BootstrapRunnerConfigPathSemanticsTest` (10 Tests) prüft GUI/headless-Semantik für vorhandene und fehlende `--config`-Pfade. |
| 3 | `.properties` als einzige Konfigurationswahrheit | **erfüllt** | `PropertiesConfigurationPortAdapter` und `GuiConfigurationPropertiesWriter` schreiben/lesen ausschließlich `.properties`. Keine zweite Konfigurationswelt. |
| 4 | Zwei Provider (Claude, OpenAI-kompatibel), genau einer aktiv | **erfüllt** | `AiProviderSelector` wählt anhand `ai.provider.active`. `AiModelCatalogDispatcher` unterstützt beide Familien. Konfigurationsbeispiel zeigt beide Blöcke. Provider-Identifikator in Versuchshistorie persistiert (`ProviderIdentifierE2ETest`, 5 Tests). |
| 5 | Hexagonale Architektur (keine JavaFX in Domain/Application) | **erfüllt** | Grep-Prüfung: kein `import javafx` in `domain` oder `application`. `adapter-in-cli` und `adapter-out` ebenfalls JavaFX-frei. JavaFX ausschließlich in `adapter-in-gui`. |
| 6 | Threadingmodell (Worker für I/O, Platform.runLater für UI) | **erfüllt** | `GuiConfigurationEditorWorkspace`, `GuiModelCatalogCoordinator`, `GuiCorrectionDialogCoordinator`, `GuiTechnicalTestCoordinator` nutzen alle explizit `Platform.runLater` für UI-Updates. Worker-Thread-Trennung ist im Code nachweisbar. |
| 7 | Naming-Regel (keine M/AP/V-Bezeichner in Code) | **erfüllt** | Grep auf `M[0-9]+`, `AP-[0-9]+`, `V[12]\.[0-9]` in allen `src/main/java` und `src/test/java` liefert keine Treffer für neue V2.0-Module (`adapter-in-gui`, `bootstrap`-Erweiterungen). |
| 8 | JavaDoc-Standard | **erfüllt** | Alle neu hinzugefügten öffentlichen Klassen und Methoden haben Klassen- und Methoden-JavaDoc. `BootstrapRunner` und `PdfUmbenennerApplication` vollständig dokumentiert. `package-info.java` vorhanden in neuen Packages. |
| 9 | Dirty-State, Schutzdialog, Validierung, technische Tests | **erfüllt** | `GuiDirtyStateTest`, `GuiUnsavedChangesGuardSmokeTest`, `GuiValidateActionSmokeTest`, `GuiTechnicalTestCoordinatorSmokeTest` belegen die Kernpfade. `GuiUnsavedChangesGuard` kapselt die Dirty-State-Logik. |
| 10 | Meldungsbereich mit vier Stufen, feldnahe Fehlermeldungen | **erfüllt** | `GuiMessageSeverity` (INFO, HINWEIS, WARNUNG, FEHLER), `GuiMessageEntry` und `GuiFieldFinding` implementieren die Anforderungen. `GuiMessageAreaSmokeTest` und `GuiMessageEntryTest` prüfen sie. |
| 11 | Modellabruf, Manueller Modellfallback, Verwerfen-Regel | **erfüllt** | `GuiModelCatalogCoordinator` implementiert automatischen Abruf bei Providerwechsel, ComboBox vs. Textfeld-Umschaltung, und Verwerfen-Regel bei Listenwechsel. `GuiModelCatalogSmokeTest` prüft die Kernpfade. |
| 12 | Korrekturhilfen mit gesammeltem Bestätigungsdialog | **erfüllt** | `GuiCorrectionDialogCoordinator` sammelt Korrekturen und steuert den Bestätigungsdialog. `ConfirmationDialogContent` kapselt die Dialog-Inhalte. `GuiCorrectionDialogCoordinatorSmokeTest` prüft den Ablauf. |
| 13 | Automatische Prompt-Erzeugung | **erfüllt** | `DefaultPromptTemplate.defaultContent()` in `application`-Schicht; `FilesystemResourceCreationAdapter` erzeugt die Datei. `DefaultPromptTemplateTest` und `FilesystemResourceCreationAdapterTest` prüfen Inhalt und Erzeugung. Beispiel in `docs/examples/prompt.txt` konsistent. |
| 14 | Windows-Pfade und gemappte Laufwerke | **erfüllt** | `FilesystemPathCheckAdapter` akzeptiert Windows-Laufwerksbuchstaben. `FilesystemPathCheckAdapterTest` (28 Tests) enthält Windows-Pfad-Szenarien. Dokumentation in `betrieb.md` und `gui-bedienanleitung.md` ausdrücklich erwähnt. |
| 15 | Legacy-Migration mit `.bak`-Sicherung | **erfüllt** | `LegacyConfigurationMigrator` in `adapter-out`; GUI-Pfad ruft `detectedLegacyConfiguration` + `migrateConfigurationIfNeeded` in `BootstrapRunner` auf. `GuiConfigurationPropertiesWriterTest` prüft Backup-Schema. |
| 16 | Keine neuen Provider über Claude/OpenAI-kompatibel hinaus | **erfüllt** | Codebase enthält ausschließlich `ClaudeAiInvocationAdapter` und `OpenAiCompatibleAiInvocationAdapter`. Kein dritter Provider. |
| 17 | Keine neuen Distributionsformate (EXE/Installer) | **erfüllt** | `pom.xml` des Bootstrap-Moduls nutzt ausschließlich `maven-shade-plugin`. Kein `launch4j`, kein `jpackage`, kein Installer. |
| 18 | Kein manueller Verarbeitungslauf aus GUI (abgelöst ab V2.1) | **erfüllt** | `adapter-in-gui` enthält keine Klasse, die `BatchRunProcessingUseCase` aus einem GUI-Event aufruft. Kein „Start"-Button, keine Batch-Ausführungslogik im GUI-Adapter. |
| 19 | Keine DB-/Historienanzeige | **erfüllt** | Kein SQLite-Lesepfad aus `adapter-in-gui`. Kein Historien-Tab. Kein Ergebnis-Browser. |
| 20 | Keine fachlichen Änderungen an Kernverarbeitung | **erfüllt** | `DefaultBatchRunProcessingUseCase`, `DocumentProcessingCoordinator`, `AiNamingService`, `AiResponseValidator` sind gegenüber dem V1.1-Freigabestand unverändert. E2E-Tests (`BatchRunEndToEndTest`, 11 Szenarien) sind alle grün. |
---
## Dokumentations-Vollständigkeitsprüfung
| Dokument | Status | Bewertung |
|---|---|---|
| `docs/betrieb.md` | vollständig | Alle V2.0-Themen abgedeckt: GUI-Standardstart, `--headless`, `--config`-Semantik für beide Modi, Plattformhinweis Windows, gemappte Laufwerke, GUI-Umfangsbegrenzung, Build- und Packaging-Abschnitt, JavaFX-Integration, headless ohne JavaFX-Initialisierung |
| `docs/gui-bedienanleitung.md` | vollständig | Alle AP-002-Themen abgedeckt: Startzustände (Abschnitte 2.12.4), alle 7 Aktionen (Abschnitte 4.14.7), vier Meldungsstufen (Abschnitt 3.2), feldnahe Fehlermeldungen (Abschnitt 5), Provider-Bedienung und Modellabruf (Abschnitt 7), API-Key-Auflösungsreihenfolge (Abschnitt 10), Dirty-State (Abschnitt 8), `.bak`-Sicherung und Legacy-Migration (Abschnitt 9), Windows-Hinweise (Abschnitt 12), bekannte Einschränkungen (Abschnitt 13) |
| `docs/examples/application.properties` | vollständig und konsistent | Alle Parameter des V2.0-Schemas vorhanden (beide Provider-Blöcke, alle Pflicht- und Optionalparameter). Kommentare zu Warnschwellen für `max.text.characters` enthalten. Default-Provider `claude` gesetzt (alphabetisch erster). Konsistent mit `GuiConfigurationTemplateFactory`. |
| `docs/examples/prompt.txt` | vollständig und konsistent | Deutschsprachiger Standardprompt. Inhaltlich identisch mit dem, was `DefaultPromptTemplate.defaultContent()` erzeugt (durch `FilesystemResourceCreationAdapterTest` nachgewiesen). JSON-Schema-Anforderungen (title, reasoning, date optional) abgebildet. |
| `README.md` | vollständig | V2.0-Hinweis im Header, GUI-Standardstart dokumentiert, `--headless` und `--config`-Beispiele vorhanden, Modul `pdf-umbenenner-adapter-in-gui` aufgelistet, Verweis auf `betrieb.md` und `gui-bedienanleitung.md`. |
---
## Release-Blocker
**Keine Release-Blocker identifiziert.**
Der vollständige Maven-Reactor-Build ist grün (1.398 Tests, 0 Failures, 0 Errors, 0 Skipped).
Alle 20 Prüfpunkte gegen die Spec-Trias sind als erfüllt bewertet. Die Dokumentation ist
vollständig und konsistent mit dem Code.
---
## Nicht blockierende Restpunkte
#### R1 ByteBuddy-Agent-Warnung bei Tests
**Thema:** Testqualität / Laufzeitwarnung
**Befund:** Beim Build erscheint wiederholt `WARNING: A Java agent has been loaded dynamically
(byte-buddy-agent-1.14.12.jar)`. Der Hinweis stammt von Mockito und tritt seit dem V1.1-Stand auf.
Er ist nicht neu, betrifft nur die Testlaufzeit und hat keinen funktionalen Einfluss auf das
produzierte Artefakt.
**Bewertung:** Kein Handlungsbedarf. Mit `-XX:+EnableDynamicAgentLoading` unterdrückbar, aber
keine Pflicht für V2.0.
#### R2 GUI-Tests ohne echten JavaFX-Rendering-Pfad
**Thema:** Testtiefe GUI
**Befund:** Die GUI-Tests (`GuiAdapterSmokeTest`, `GuiEditorRegressionSmokeTest` usw.) laufen
unter headless JavaFX (Monocle) und prüfen View-Modell-Logik, Zustandsübergänge und
Koordinatoren-Verhalten. Das visuelle Rendering der Oberfläche (Farbgebung der Meldungspräfixe,
Layout-Details) ist nicht automatisiert geprüft. Dies entspricht der in CLAUDE.md definierten
GUI-Teststrategie (kein TestFX über Monocle hinaus) und ist keine Abweichung vom Ziel.
**Bewertung:** Kein Handlungsbedarf. Entspricht der Teststrategie-Vorgabe.
---
## Bewusst außerhalb V2.0 liegende Themen (V2.1+)
Die folgenden Themen wurden im V2.0-Umfang nachweislich **nicht** implementiert und sind
ausdrücklich für spätere Ausbaustufen vorgesehen:
- **Manueller Verarbeitungslauf aus der GUI** (V2.1+) **umgesetzt ab V2.1** (Tab „Verarbeitungslauf")
- **DB-/Historienansicht** in der GUI (V2.x+)
- **Kosten-Tracking** und Token-/Preisberechnung (V2.x+)
- **EXE-Wrapper / Installer** (V3+) **umgesetzt ab V3**: EXE-Wrapper (M14), MSI-Installer (M15)
- **Weitere KI-Provider** über Claude und OpenAI-kompatibel hinaus (V3+)
- **Automatischer Fallback zwischen Providern** (V3+)
- **Profilverwaltung mit mehreren Konfigurationen je Provider** (V3+)
- **Plattformübergreifender offizieller GUI-Support** (V3+)
---
## Gesamtbewertung V2.0-Stand
| Klassifikation | Anzahl | Beschreibung |
|---|---|---|
| Release-Blocker | **0** | |
| Nicht blockierende Restpunkte | **2** | R1 ByteBuddy-Warnung, R2 Testtiefe GUI-Rendering |
| Bewusst außerhalb V2.0 | **8** | Manueller Lauf, Historienansicht, Kosten-Tracking, EXE, weitere Provider, Fallback, Profile, Cross-Platform |
**Build:** ERFOLGREICH · 1.398 Tests · 0 Failures · 0 Errors · Laufzeit 01:18 min
**Alle 20 Spezifikations-Prüfpunkte:** erfüllt
**Dokumentation:** vollständig und konsistent
---
# V2.9-Fixes (Stand 2026-04-24)
Die folgenden Issues wurden nach dem V2.0-Abschluss behoben und sind im aktuellen Stand integriert.
| Issue | Titel | Status |
|---|---|---|
| #27 | Mausrad-Seitenwechsel und zuverlässiger Seitenanfang in PDF-Vorschau | **behoben** |
| #28 | Anwendung standardmäßig im Vollbild starten | **behoben** |
| #29 | Eigenes PDF-Rendering mit PDFBox statt PDFViewFX | **behoben** |
| #33 | Letzte Konfigurationsdatei beim Neustart automatisch laden | **behoben** |
### Beschreibung der Fixes
**#27 / #29 PDF-Vorschau-Stabilität und PDFBox-Migration:**
Mehrere aufeinanderfolgende Fixes stabilisierten die PDF-Vorschau. Zunächst wurden
Scroll-Schutz und zuverlässiger Seitenanfang per ImageView-Listener verbessert.
Im letzten Schritt (#29) wurde die externe PDFViewFX-Abhängigkeit vollständig
durch direktes Rendering via `PDFRenderer.renderImageWithDPI` (Apache PDFBox, 120 DPI)
ersetzt. Lazy Rendering mit In-Memory-Cache und das „latest preview request wins"-Prinzip
blieben erhalten.
**#28 Vollbild-Start:**
`stage.setMaximized(true)` in `PdfUmbenennerGuiApplication.start()` sorgt dafür, dass
das Fenster beim Start automatisch maximiert wird.
**#33 Letzte Konfiguration automatisch laden:**
`GuiConfigurationEditorWorkspace` speichert den Pfad einer erfolgreich geladenen
Konfigurationsdatei in `java.util.prefs.Preferences` (Schlüssel `lastConfigPath`).
Beim nächsten Start wird diese Datei automatisch geladen, sofern sie noch existiert.
Fehlt die Datei, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
---
> **Hinweis:** Für die Ausbaustufen V2.1 bis V2.9 wurden keine separaten Befundlisteneinträge
> erstellt. Befunde, Fixes und Verbesserungen dieser Stufen sind in den Gitea-Issues dokumentiert.
+443 -12
View File
@@ -8,13 +8,100 @@ und legt eine Kopie im konfigurierten Zielordner ab. Die Quelldatei bleibt unver
--- ---
## Startmodi und Betriebsmodell (V2.0)
Ab V2.0 enthält die Anwendung zwei Startmodi in **einem gemeinsamen ausführbaren JAR**:
| Startmodus | Beschreibung |
|---|---|
| **GUI-Start** (Standard) | Öffnet die JavaFX-Desktop-GUI. Wird verwendet, wenn kein `--headless` angegeben ist. |
| **headless Betrieb** | Klassischer Batch-/Scheduler-Betrieb ohne grafische Oberfläche. Wird über `--headless` aktiviert. |
### CLI-Optionen
| Option | Beschreibung |
|---|---|
| *(keine Argumente)* | GUI-Standardstart |
| `--headless` | Aktiviert den headless Batch-Betrieb (wie vor V2.0) |
| `--config <pfad>` | Zeigt explizit auf eine `.properties`-Konfigurationsdatei (für GUI und headless) |
`--config` und `--headless` können kombiniert werden:
```
java -jar pdf-umbenenner-bootstrap-*.jar --headless --config C:\Pfad\zur\config.properties
```
### Verhalten bei fehlender oder ungültiger `--config`-Datei
| Startmodus | Datei nicht vorhanden | Datei vorhanden, aber ungültig |
|---|---|---|
| **headless** | Harter Startfehler, Exit-Code `1`, kein Fallback | Harter Startfehler, Exit-Code `1` |
| **GUI** | Fehlermeldung in der GUI, danach Verhalten wie ohne `--config` (Willkommenstext) | Fehlermeldung in der GUI, Konfiguration nicht geladen |
Im headless Betrieb ist ein nicht vorhandener `--config`-Pfad ein **harter Startfehler**. Ein stiller
Fallback auf das Default-Verhalten ist in diesem Fall ausdrücklich unzulässig.
### Verhalten bei GUI-Startfehlern
Tritt vor der erfolgreichen Anzeige der grafischen Oberfläche ein nicht behebbarer Fehler auf
(z. B. fehlende JavaFX-Laufzeit, Bootstrap-Fehler), beendet sich die Anwendung mit Exit-Code `1`.
### Plattform und Laufwerksbuchstaben
Die GUI wird **offiziell nur unter Windows** unterstützt. Der headless Betrieb bleibt für den
Windows Server-Betrieb geeignet.
Gemappte Netzlaufwerke wie `S:\` oder `H:\` werden ausdrücklich unterstützt. Eine Ablehnung
solcher Pfade allein wegen eines dahinterliegenden UNC-Pfads ist unzulässig.
### Startverhalten der GUI
Die GUI startet **maximiert** (Vollbild). Beim Start wird die zuletzt geladene
Konfigurationsdatei automatisch geladen. Der Pfad wird in den Windows-Benutzereinstellungen
gespeichert (`java.util.prefs.Preferences`). Existiert die Datei beim nächsten Start nicht
mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
### Umfang der GUI
Die GUI enthält 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 ## Voraussetzungen
- Java 21 (JRE oder JDK) - Zugang zu einem KI-Dienst (API-Schlüssel erforderlich; unterstützte Provider: OpenAI-kompatibel, Anthropic Claude)
- Zugang zu einem OpenAI-kompatiblen KI-Dienst (API-Schlüssel erforderlich)
- Quellordner mit OCR-verarbeiteten PDF-Dateien - Quellordner mit OCR-verarbeiteten PDF-Dateien
- Schreibzugriff auf Zielordner und Datenbankverzeichnis - Schreibzugriff auf Zielordner und Datenbankverzeichnis
### Java-Laufzeitumgebung
- Bei Verwendung des **Shade-JAR** direkt: **Java 21 JRE** auf dem Zielsystem erforderlich.
- Bei Verwendung des **Windows-Installers (V3.0)**: **keine** separate Java-Installation notwendig
die JRE 21 ist in der installierten Anwendung eingebettet.
--- ---
## Start des ausführbaren JAR ## Start des ausführbaren JAR
@@ -26,16 +113,46 @@ Das ausführbare JAR wird durch den Maven-Build im Verzeichnis
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
``` ```
Die Anwendung liest die Konfiguration aus `config/application.properties` relativ zum Ohne weitere Argumente öffnet sich die **GUI** (Standardstart ab V2.0).
Für den **headless Betrieb** (Batch-/Scheduler-Start):
```
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless
```
Die Anwendung liest die Konfiguration standardmäßig aus `config/application.properties` relativ zum
Arbeitsverzeichnis, in dem der Befehl ausgeführt wird. Arbeitsverzeichnis, in dem der Befehl ausgeführt wird.
### Konsolen-Encoding unter Windows
Die Anwendung schreibt alle Log-Ausgaben in UTF-8. Windows-Konsolen (PowerShell, CMD) verwenden
standardmäßig den OEM-Codepage (z. B. CP850), was zu unlesbaren Sonderzeichen führt.
**Lösung:** Konsole vor dem Start auf UTF-8 umschalten:
```
chcp 65001
java -jar pdf-umbenenner-bootstrap-*.jar --headless
```
Alternativ kann die UTF-8-Ausgabe auch als JVM-Argument angegeben werden (Java 17+):
```
java -Dstdout.encoding=UTF-8 -jar pdf-umbenenner-bootstrap-*.jar --headless
```
> **Hinweis:** Die mitgelieferten Batch-Dateien (`PDF-KI-Renamer.bat`, `PDF-KI-Renamer-GUI.bat`)
> rufen `chcp 65001` automatisch auf. Der Windows Task Scheduler schreibt Log-Ausgaben in eine
> Protokolldatei, die stets UTF-8-kodiert ist dort entsteht kein Anzeigeproblem.
### Start über Windows Task Scheduler ### Start über Windows Task Scheduler
Empfohlene Startsequenz für den Windows Task Scheduler: Empfohlene Startsequenz für den headless Betrieb über den Windows Task Scheduler:
1. Aktion: Programm/Skript starten 1. Aktion: Programm/Skript starten
2. Programm: `java` 2. Programm: `java`
3. Argumente: `-jar C:\Pfad\zur\Installation\pdf-umbenenner-bootstrap\target\pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar` 3. Argumente: `-jar C:\Pfad\zur\Installation\pdf-umbenenner-bootstrap\target\pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless`
4. Starten in: `C:\Pfad\zur\Installation` (muss das Verzeichnis mit `config\application.properties` und `config\prompts\` enthalten) 4. Starten in: `C:\Pfad\zur\Installation` (muss das Verzeichnis mit `config\application.properties` und `config\prompts\` enthalten)
> **Hinweis:** Das „Starten in"-Verzeichnis ist das Arbeitsverzeichnis der Anwendung. > **Hinweis:** Das „Starten in"-Verzeichnis ist das Arbeitsverzeichnis der Anwendung.
@@ -43,11 +160,26 @@ Empfohlene Startsequenz für den Windows Task Scheduler:
> `config/prompts/` müssen relativ zu diesem Verzeichnis erreichbar sein. Der JAR-Pfad > `config/prompts/` müssen relativ zu diesem Verzeichnis erreichbar sein. Der JAR-Pfad
> in den Argumenten muss absolut oder relativ zum Starten-in-Verzeichnis korrekt angegeben sein. > in den Argumenten muss absolut oder relativ zum Starten-in-Verzeichnis korrekt angegeben sein.
Alternativ kann über `--config` ein expliziter Konfigurationspfad angegeben werden:
```
java -jar ... --headless --config S:\Betrieb\meine-config.properties
```
> **Wichtig:** Zeigt `--config` auf eine nicht vorhandene Datei, bricht die Anwendung mit Exit-Code `1` ab.
> Es findet kein stiller Fallback auf `config/application.properties` statt.
--- ---
## Konfiguration ## Konfiguration
Die Konfiguration wird aus `config/application.properties` geladen. Die Konfiguration wird aus `config/application.properties` geladen.
Ein vollständiges Konfigurationsbeispiel mit allen unterstützten Parametern,
realistischen Windows-Pfaden und erklärenden Kommentaren liegt unter:
- [`docs/examples/application.properties`](examples/application.properties)
Vorlagen für lokale und Test-Konfigurationen befinden sich in: Vorlagen für lokale und Test-Konfigurationen befinden sich in:
- `config/application-local.example.properties` - `config/application-local.example.properties`
@@ -64,6 +196,7 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in:
| `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) | | `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) |
| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) | | `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) |
| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) | | `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) |
| `max.title.length` | Maximale Länge des Basistitels in Zeichen (ganzzahlig, 10..120, Default 60). Werte unter 10 oder über 120 verhindern den Start. Werte 1039 und 100120 erzeugen eine Startwarnung. |
| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) | | `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) |
### Provider-Parameter ### Provider-Parameter
@@ -155,15 +288,45 @@ Der Dateiname der Prompt-Datei dient als Prompt-Identifikator in der Versuchshis
(SQLite) und ermöglicht so die Nachvollziehbarkeit, welche Prompt-Version für welchen (SQLite) und ermöglicht so die Nachvollziehbarkeit, welche Prompt-Version für welchen
Verarbeitungsversuch verwendet wurde. Verarbeitungsversuch verwendet wurde.
Eine Vorlage befindet sich in `config/prompts/template.txt` und kann direkt verwendet oder Eine angepasste Vorlage befindet sich in `config/prompts/template.txt` und kann direkt
an den jeweiligen KI-Dienst angepasst werden. verwendet oder an den jeweiligen KI-Dienst angepasst werden.
Fehlt die konfigurierte Prompt-Datei, erzeugt die GUI beim Ausführen der technischen Tests
automatisch eine deutsche Standardvorlage am konfigurierten Pfad. Ein Beispiel dieser
Standardvorlage liegt unter [`docs/examples/prompt.txt`](examples/prompt.txt).
Die Anwendung ergänzt den Prompt automatisch um: Die Anwendung ergänzt den Prompt automatisch um:
- einen Dokumenttext-Abschnitt - einen Dokumenttext-Abschnitt
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date` - eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
Der Prompt in `template.txt` muss deshalb **keine** JSON-Formatanweisung enthalten ### Prompt-Pfad-Auflösung je Betriebsart
nur den inhaltlichen Auftrag an die KI.
Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst,
wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab:
| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert |
|---|---|---|
| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` |
| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` |
| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` |
| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen |
> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad**
> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis
> immer eindeutig auffindbar insbesondere beim Start über den Windows Task Scheduler,
> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann.
### Bearbeitung über den GUI-Prompt-Tab
Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und
gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn
das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem
Fall wird kein stiller Fallback durchgeführt).
Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert
war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"),
aktualisiert sich der Prompt-Tab nicht automatisch in diesem Fall sollte die GUI neu gestartet
oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden.
--- ---
@@ -182,7 +345,7 @@ YYYY-MM-DD - Titel(1).pdf
YYYY-MM-DD - Titel(2).pdf YYYY-MM-DD - Titel(2).pdf
``` ```
Das Suffix zählt nicht zu den 20 Zeichen des Basistitels. Das Suffix zählt nicht zur konfigurierten maximalen Titellänge des Basistitels.
--- ---
@@ -279,11 +442,279 @@ Sie muss nicht manuell verwaltet werden das Schema wird beim Start automatis
--- ---
## Build und Packaging
### Gemeinsames ausführbares JAR
Die gesamte Anwendung wird als **ein einziges ausführbares JAR** ausgeliefert, das GUI-Start
und headless Batch-Betrieb vereint. Eine separate JavaFX-Installation ist nicht erforderlich.
Das JAR wird vom Maven-Shade-Plugin im Modul `pdf-umbenenner-bootstrap` erzeugt.
Nach einem erfolgreichen Build liegt es unter:
```
pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
```
Dieses JAR enthält alle Abhängigkeiten inklusive der JavaFX-Plattformbibliotheken
für Windows (Classifier `win`). Die nativen JavaFX-DLLs werden beim GUI-Start
von JavaFX selbst in ein temporäres Verzeichnis extrahiert.
### Integrierte JavaFX-Laufzeit
JavaFX ist als Maven-Dependency im Modul `pdf-umbenenner-adapter-in-gui` mit
Windows-Classifier deklariert (`javafx-base:win`, `javafx-graphics:win`,
`javafx-controls:win`). Das Shade-JAR schließt diese Bibliotheken ein, sodass
der GUI-Start ohne separate JavaFX-Installation auf dem Zielsystem funktioniert.
Nur das Modul `pdf-umbenenner-adapter-in-gui` hängt direkt von JavaFX ab.
Die Module `domain`, `application`, `adapter-in-cli` und `adapter-out` sind
vollständig JavaFX-frei.
### Headless-Start ohne JavaFX-Initialisierung
Beim headless Start (`--headless`) wird JavaFX **nicht** initialisiert. Der
`GuiAdapter` wird nur dann instanziiert und gestartet, wenn der Startmodus GUI ist.
JavaFX-Klassen sind zwar im Shade-JAR enthalten, werden im headless Pfad jedoch
nicht geladen. Headless läuft damit auch auf Windows Server-Systemen ohne
JavaFX-fähige Grafiklaufzeit.
### Windows-Installer (V3.0)
Ab V3.0 steht neben dem Shade-JAR ein vollwertiger **MSI-Installer** für Windows 10/11 (x64)
und Windows Server 2022 (x64) bereit. Der Installer enthält eine eingebettete JRE 21 und
benötigt keine separate Java-Installation auf dem Zielsystem. Das Shade-JAR bleibt das
primäre Distributionsartefakt; der MSI ist eine zusätzliche Option für Systeme ohne
Java-Installation und für den Standard-Installationspfad nach `C:\Program Files\`.
> **Hinweis zur CI-Umgebung:** Der MSI-Build ist Windows-only (`jpackage` + WiX Toolset 3.x).
> Jenkins läuft im Linux-Container auf dem Synology NAS und kann kein MSI erzeugen.
> Der MSI-Build wird bewusst manuell auf der Windows-Entwicklungsmaschine ausgeführt.
**Voraussetzungen für den Installer-Build (nur auf der Entwicklungsmaschine):**
- Windows x64
- JDK 21 im PATH
- [WiX Toolset 3.x](https://wixtoolset.org/) im PATH
**MSI bauen:**
```powershell
.\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests
```
Der normale Build (`mvn clean verify`) ist vom Profil `release` vollständig unberührt
und benötigt **kein** WiX Toolset.
Das Ergebnis liegt unter:
```
pdf-umbenenner-packaging/target/dist/
PDF-KI-Renamer-2.5.0.msi ← Windows-Installer
PDF-KI-Renamer.bat ← Headless-Start (zusätzlich kopiert)
PDF-KI-Renamer-GUI.bat ← GUI-Start (zusätzlich kopiert)
```
**Installationsverzeichnis:**
Der Installer legt die Anwendung nach `C:\Program Files\PDF KI Renamer\` ab.
Beide Batch-Dateien landen ebenfalls dort. Der Installer erstellt:
- einen Startmenü-Eintrag in der Gruppe `PDF KI Renamer` (startet die GUI)
- einen Desktop-Shortcut (startet die GUI)
Die Deinstallation erfolgt über „Programme und Features" in der Windows-Systemsteuerung.
Vom Installer angelegte Dateien werden entfernt; Nutzerdaten unter `C:\ProgramData\PDF KI Renamer\`
(Konfiguration, Logs, SQLite-Datenbank) bleiben erhalten.
**Konfigurationsverzeichnis (`ProgramData`):**
Das empfohlene Konfigurationsverzeichnis für den produktiven Betrieb ist:
```
C:\ProgramData\PDF KI Renamer\config\
```
Die Anwendung löst dieses Verzeichnis **nicht** automatisch auf. Der Pfad zur
Konfigurationsdatei muss weiterhin explizit über `--config` angegeben werden
(siehe „CLI-Optionen"). Der Installer legt eine Beispiel-Konfiguration namens
`application.example.properties` neben den installierten Artefakten im
Installationsverzeichnis ab. **Der Betreiber muss diese Beispieldatei manuell nach**
`C:\ProgramData\PDF KI Renamer\config\` **kopieren und anpassen.**
**Beispielaufruf headless mit installierter Anwendung:**
```powershell
"C:\Program Files\PDF KI Renamer\PDF-KI-Renamer.bat" --config "C:\ProgramData\PDF KI Renamer\config\application.properties"
```
**Hinweis:** Der MSI ist nicht signiert. Beim Installieren erscheint eine
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
**Empfehlung für Pfade im MSI-Betrieb:**
Für den MSI-Betrieb (Startmenü, Task Scheduler) müssen alle Dateipfade als **absolute Pfade**
konfiguriert werden. Relative Pfade werden relativ zum Installationsverzeichnis
`C:\Program Files\PDF KI Renamer\` aufgelöst, das **schreibgeschützt** ist. Dadurch
schlagen Schreibversuche (Logs, SQLite-Datenbank, Lock-Datei) ohne Fehlermeldung fehl.
> **Warnung Relative Pfade im MSI-Betrieb nicht verwenden:**
> Pfade wie `./logs`, `./work/local/logs` oder `logs/` werden im MSI-Betrieb relativ
> zum Installationsverzeichnis aufgelöst. Das Installationsverzeichnis ist für normale
> Benutzerkonten schreibgeschützt. Log4j2 scheitert dann still, ohne eine sichtbare
> Fehlermeldung zu erzeugen.
> **Warnung Backslashes in `.properties`-Dateien:**
> In Java-`.properties`-Dateien werden Backslashes (`\`) als Escape-Zeichen interpretiert.
> Windows-Pfade wie `C:\Users\Funny\Logs` müssen entweder mit Forward-Slashes
> (`C:/Users/Funny/Logs`) oder mit doppelten Backslashes (`C:\\Users\\Funny\\Logs`)
> angegeben werden. Einfache Backslashes werden stillschweigend falsch interpretiert.
Betroffene Parameter:
| Parameter | Empfehlung |
|---|---|
| `log.directory` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/logs` |
| `runtime.lock.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock` |
| `prompt.template.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/prompts/template.txt` |
| `sqlite.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/pdf-umbenenner.db` |
Das empfohlene Konfigurationsverzeichnis für alle schreibbaren Daten im MSI-Betrieb ist
`C:\ProgramData\PDF KI Renamer\`, da dieses Verzeichnis standardmäßig für alle
Benutzerkonten schreibbar ist und bei der Deinstallation erhalten bleibt.
**Diagnose: Log-Datei-Prüfpunkt in den technischen Tests**
Die technischen Tests (Schaltfläche „Technische Tests ausführen" im Konfigurationseditor)
enthalten einen dedizierten Prüfpunkt **„Log-Verzeichnis beschreibbar"**, der anzeigt:
- den konfigurierten `log.directory`-Wert (roh und als aufgelöster absoluter Pfad),
- ob das Verzeichnis vorhanden und beschreibbar ist,
- den tatsächlichen Log-Dateipfad aus der laufenden Log4j2-Konfiguration.
Ein nicht beschreibbares Log-Verzeichnis wird als **Warnung** angezeigt, nicht als Fehler
(die Anwendung kann ohne Datei-Logging laufen). Der Prüfpunkt hilft, den typischen
MSI-Betriebsfehler relatives `log.directory` auf schreibgeschütztem Installationspfad
frühzeitig zu erkennen.
### MSI-Release-Checkliste
Die folgende Checkliste ist vor jeder MSI-Auslieferung manuell abzuarbeiten.
- [ ] Neuinstallation auf sauberer Windows-Umgebung ohne vorinstalliertes Java
- [ ] Installation in Installationspfad **mit Leerzeichen** (z. B. `C:\Program Files\PDF KI Renamer\`)
- [ ] Upgrade von installiertem Vorgänger-MSI (kein manuelles Deinstallieren)
- [ ] GUI-Start über Startmenü-Eintrag
- [ ] Headless-Start über `PDF-KI-Renamer.bat` im Windows Task Scheduler
- [ ] Desktop-Shortcut vorhanden oder Einschränkung hier dokumentiert
- [ ] App-Version `3.0.x` im Windows-Installer sichtbar („Programme und Features")
- [ ] Deinstallation sauber Konfiguration unter `C:\ProgramData\PDF KI Renamer\` bleibt erhalten
- [ ] SmartScreen-Warnung erscheint und wird durch „Weitere Informationen → Trotzdem ausführen" bestätigt
- [ ] BAT-Dateien funktionieren bei Installationspfad mit Leerzeichen
- [ ] Anwendungsstart **ohne Entwicklungs-JDK** erfolgreich: GUI-Start, PDF laden und rendern, Verarbeitungslauf starten, Verlaufs-Tab öffnen (Verifikation der `addModules`-Liste)
> **Hinweis zur JDK-freien Laufzeit-Verifikation:** Nur ein erfolgreicher Test
> auf einem System ohne installiertes JDK bestätigt die Vollständigkeit der
> `addModules`-Liste in `pdf-umbenenner-packaging/pom.xml`. Die aktuelle Liste
> wurde per `jdeps --print-module-deps --ignore-missing-deps` ermittelt;
> vollständige Ausgabe in `pdf-umbenenner-packaging/jdeps-output.txt`.
### Build-Kommandos
**Vollständiger Reactor-Build** (alle Module, Tests, Packaging):
```powershell
.\mvnw.cmd clean verify
```
Auf Unix-Systemen (headless CI):
```bash
./mvnw clean verify
```
**Nur das ausführbare JAR erzeugen** (überspringt Tests):
```powershell
.\mvnw.cmd clean package -pl pdf-umbenenner-bootstrap --also-make -DskipTests
```
**Selektiver Reactor-Build** (ohne Coverage-Modul, z. B. während der Entwicklung):
```powershell
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make
```
### Technische Hinweise zum Shade-JAR
- Signaturdateien (`*.SF`, `*.DSA`, `*.RSA`) signierter JARs (u. a. JavaFX) werden
beim Shading entfernt, da sie im zusammengeführten JAR ungültig wären.
- JPMS-Moduldeskriptoren (`module-info.class`) werden entfernt, da JavaFX als
modulares Framework mit dem nicht-modularen Fat-JAR-Modell kollidieren würde.
- `META-INF/services`-Einträge aus allen Abhängigkeiten werden durch den
`ServicesResourceTransformer` zusammengeführt statt überschrieben.
- Der Main-Class-Eintrag im Manifest verweist auf
`de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication`.
Diese Klasse erweitert bewusst **nicht** `javafx.application.Application`,
um den JavaFX-Modul-System-Launcher-Check zu umgehen, der Fat-JAR-Ausführung
blockieren würde. Der GUI-Pfad ruft `Application.launch(...)` explizit auf.
---
## GUI: Selektive Wiederverarbeitung und Status-Reset
Die GUI ermöglicht nach Abschluss eines Verarbeitungslaufs zwei zusätzliche Aktionen auf der Ergebnisliste:
### Selektion in der Ergebnisliste
Die Ergebnisliste enthält eine **Checkbox pro Zeile** sowie eine **Master-Checkbox** zum Auswählen aller Einträge.
- Auswahl erfolgt wie im Windows Explorer mit **Shift/Strg-Mehrfachselektion**
- Alle vier Statustypen sind selektierbar: erfolgreich, retryable, permanent fehlgeschlagen, übersprungen
- Während eines Laufs ist die Selektion **gesperrt**
### Button „Erneut verarbeiten"
**Aktion:** DB-Status zurücksetzen + sofortiger Mini-Lauf nur für ausgewählte Dateien.
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
- Der Mini-Lauf arbeitet auf einem Snapshot der beim Klick ausgewählten Einträge
- Nicht ausgewählte Einträge bleiben unverändert in der Liste
- Verhalten identisch zu regulärem Lauf (gleiche Anwendungslogik, nur eingeschränkte Dateimenge)
**Besonderheit bei identischem Zieldateinamen:** Verarbeitet der KI-Provider wieder denselben Dateinamen wie ein vorangegangener erfolgreicher Lauf, erhält der Eintrag **Status erfolgreich** es wird keine erneute Kopie erzeugt, kein Fehler.
**Fehlende Quelldatei:** Ist die Datei zum Zeitpunkt des Mini-Laufs nicht mehr vorhanden, erhält der Eintrag **Status permanent fehlgeschlagen** mit Meldung „Quelldatei nicht gefunden".
### Button „Status zurücksetzen"
**Aktion:** Nur DB-Status zurücksetzen, keine sofortige Verarbeitung.
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
- Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt wartet auf nächsten Lauf"**
- Beim nächsten regulären Lauf werden zurückgesetzte Dateien automatisch mitgenommen
- **Best-effort-Reset:** Erfolgreiche und fehlgeschlagene Resets werden pro Eintrag einzeln durchgeführt; Zusammenfassung zeigt Erfolge und Fehler
### Verhalten während eines Mini-Laufs
- Der **Abbrechen-Button** gilt auch für Mini-Läufe (Soft-Stop)
- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt**
- Nach Soft-Stop: bereits verarbeitete Einträge behalten neuen Status, noch nicht gestartete zurückgesetzte Einträge warten auf nächsten regulären Lauf
- Fortschrittsbalken zeigt Fortschritt für die ausgewählte Dateimenge
---
## Weitere Dokumentation
Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md) beschrieben.
---
## Systemgrenzen ## Systemgrenzen
- Nur OCR-verarbeitete, durchsuchbare PDF-Dateien werden verarbeitet - Nur OCR-verarbeitete, durchsuchbare PDF-Dateien werden verarbeitet
- Keine eingebaute OCR-Funktion - Keine eingebaute OCR-Funktion
- Kein Web-UI, keine REST-API, keine interaktive Bedienung - Kein Web-UI, keine REST-API
- Kein interner Scheduler der Start erfolgt extern (z. B. Windows Task Scheduler) - Die GUI ermöglicht Konfiguration, Validierung, technische Diagnose und die Ausführung von Verarbeitungsläufen mit integrierter PDF-Vorschau und editierbarem Dateiname
- Kein interner Scheduler der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`)
- Quelldateien werden nie überschrieben, verschoben oder gelöscht - Quelldateien werden nie überschrieben, verschoben oder gelöscht
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen - Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
- Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet
+128
View File
@@ -0,0 +1,128 @@
# PDF Umbenenner vollstaendiges Konfigurationsbeispiel (V2.0)
#
# Diese Datei zeigt alle unterstuetzten Konfigurationsparameter mit realistischen
# Windows-Pfaden und erklaerenden Kommentaren.
#
# Fuer den produktiven Einsatz: Datei nach config/application.properties kopieren
# und Werte anpassen. Der headless Batch-Betrieb liest standardmaessig
# config/application.properties relativ zum Arbeitsverzeichnis.
#
# Die GUI schlaegt beim "Speichern unter" denselben Pfad vor.
# ---------------------------------------------------------------------------
# Pfade
# ---------------------------------------------------------------------------
# Quellordner: Ordner, aus dem OCR-verarbeitete PDF-Dateien gelesen werden.
# Der Ordner muss vorhanden und lesbar sein.
# Beispiel: gemapptes Netzlaufwerk (wird ausdruecklich unterstuetzt)
source.folder=S:\\Eingang
# Zielordner: Ordner, in den die umbenannten Kopien abgelegt werden.
# Wird automatisch angelegt, wenn er noch nicht existiert (Schreibzugriff erforderlich).
target.folder=S:\\Archiv
# SQLite-Datenbankdatei fuer Bearbeitungsstatus und Versuchshistorie.
# Das uebergeordnete Verzeichnis muss vorhanden sein.
sqlite.file=S:\\Archiv\\pdf-umbenenner.db
# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator
# in der Versuchshistorie und ermoeg licht die Nachvollziehbarkeit der verwendeten
# Prompt-Version. Fehlt die Datei, kann die GUI sie automatisch anlegen (deutsche
# Standardvorlage). Ein Beispiel der Standardvorlage liegt unter docs/examples/prompt.txt.
prompt.template.file=S:\\Archiv\\prompt.txt
# ---------------------------------------------------------------------------
# Aktiver KI-Provider
# ---------------------------------------------------------------------------
# Genau ein Provider ist aktiv. Kein automatischer Fallback, keine parallele Nutzung.
# Erlaubte Werte: claude, openai-compatible
#
# Hinweis: Die GUI-Standardvorlage ("Neu") setzt standardmaessig "claude" als aktiven
# Provider, weil Claude alphabetisch der erste unterstuetzte Provider ist.
ai.provider.active=claude
# ---------------------------------------------------------------------------
# Provider: Anthropic Claude
# ---------------------------------------------------------------------------
# Wird verwendet, wenn ai.provider.active=claude gesetzt ist.
# Basis-URL des Anthropic-Dienstes (Standard: https://api.anthropic.com)
ai.provider.claude.baseUrl=https://api.anthropic.com
# Modellname (z. B. claude-3-5-sonnet-20241022)
ai.provider.claude.model=claude-3-5-sonnet-20241022
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
ai.provider.claude.timeoutSeconds=60
# API-Schluessel fuer Anthropic.
# Vorrangreihenfolge: Umgebungsvariable ANTHROPIC_API_KEY > dieser Wert.
# Das Feld darf leer bleiben, wenn die Umgebungsvariable gesetzt ist.
ai.provider.claude.apiKey=
# ---------------------------------------------------------------------------
# Provider: OpenAI-kompatibel
# ---------------------------------------------------------------------------
# Wird verwendet, wenn ai.provider.active=openai-compatible gesetzt ist.
# Geeignet fuer OpenAI selbst und jeden API-kompatiblen Drittanbieter.
# Basis-URL des KI-Dienstes (ohne Pfadsuffix wie /chat/completions).
ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1
# Modellname (z. B. gpt-4o-mini)
ai.provider.openai-compatible.model=gpt-4o-mini
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
ai.provider.openai-compatible.timeoutSeconds=30
# API-Schluessel fuer OpenAI-kompatible Dienste.
# Vorrangreihenfolge: OPENAI_COMPATIBLE_API_KEY (Umgebungsvariable) >
# PDF_UMBENENNER_API_KEY (veraltete Umgebungsvariable, weiterhin akzeptiert) >
# ai.provider.openai-compatible.apiKey (dieser Wert)
# Das Feld darf leer bleiben, wenn die Umgebungsvariable gesetzt ist.
ai.provider.openai-compatible.apiKey=
# ---------------------------------------------------------------------------
# Verarbeitungslimits
# ---------------------------------------------------------------------------
# Maximale Anzahl historisierter transienter Fehlversuche pro Dokument.
# Muss eine ganze Zahl >= 1 sein. Wert 0 ist ungueltige Konfiguration.
max.retries.transient=3
# Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als
# deterministischer Inhaltsfehler behandelt (kein KI-Aufruf).
max.pages=10
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
# Werte bis 1000: unkritisch.
# Werte 1001-3000: erhoehte KI-Kosten moeglich (Warnung in der GUI).
# Werte ab 3001: deutlich erhoehte KI-Kosten moeglich (starke Warnung in der GUI).
# Standardvorlage der GUI: 1000.
max.text.characters=1000
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
# Werte unter 10 oder ueber 120 verhindern den Start.
# Werte 10-19: Warnung (fuer die meisten Dokumente nicht empfohlen).
# Werte 100-120: Warnung (Dateiname wird sehr lang, Kompatibilitaet mit verschluesselten Volumes pruefen).
max.title.length=60
# ---------------------------------------------------------------------------
# Optionale Parameter
# ---------------------------------------------------------------------------
# Lock-Datei fuer den Startschutz (verhindert parallele Instanzen).
# Ohne Konfiguration: pdf-umbenenner.lock im Arbeitsverzeichnis.
runtime.lock.file=S:\\Archiv\\pdf-umbenenner.lock
# Log-Verzeichnis. Ohne Konfiguration: ./logs/ im Arbeitsverzeichnis.
log.directory=S:\\Archiv\\logs
# Log-Level (DEBUG, INFO, WARN, ERROR). Standard: INFO.
log.level=INFO
# Sensible KI-Inhalte (vollstaendige Rohantwort und Reasoning) ins Log schreiben.
# Erlaubte Werte: true oder false. Standard: false (geschuetzt).
# Die KI-Rohantwort wird unabhaengig davon immer in der SQLite-Datenbank gespeichert.
log.ai.sensitive=false
+23
View File
@@ -0,0 +1,23 @@
Du bist ein Assistent für ein deutsches Dokumentenverwaltungssystem.
Deine Aufgabe ist es, aus dem Inhalt einer bereits OCR-verarbeiteten PDF-Datei
einen aussagekräftigen, kurzen und normierten Dateinamensvorschlag zu erstellen.
Antworte ausschließlich mit einem validen JSON-Objekt im folgenden Schema:
{
"date": "YYYY-MM-DD",
"title": "Kurztitel auf Deutsch",
"reasoning": "Kurze Begründung auf Deutsch"
}
Regeln:
- Das Feld "title" ist verpflichtend.
- Das Feld "reasoning" ist verpflichtend.
- Das Feld "date" ist optional. Wenn kein belastbares Datum aus dem Dokument eindeutig ableitbar ist, lass das Feld weg. Kein Datum erfinden.
- Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15).
- Der Titel ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt.
- Der Titel hat maximal 20 Zeichen (Basistitel ohne Suffix).
- Keine generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF".
- Keine Sonderzeichen außer Leerzeichen im Titel.
- Eigennamen bleiben unverändert.
- Umlaute und ß sind erlaubt.
- Kein Text außerhalb des JSON-Objekts.
+94
View File
@@ -0,0 +1,94 @@
# V2.0-Freigabe
## Geprüfter Stand
- Git-Branch: `main`
- Git-Commit (HEAD, zum Zeitpunkt der Prüfung): `1bb7a427357c73039c09a8e1bfe351dee54df765`
- Datum der Prüfung: 2026-04-20
---
## Ausgeführte Prüfungen
| Prüfung | Ergebnis |
|---|---|
| Vollständiger Maven-Reactor-Build (`clean verify`, alle 6 Module, `-DskipPitest=true`) | **ERFOLGREICH** |
| Unit-Tests gesamt | **1.398 Tests, 0 Failures, 0 Errors, 0 Skipped** |
| Integrations-/Smoke-Tests (`ExecutableJarSmokeTestIT`, Bootstrap) | **5 Tests, alle grün** |
| Shaded-JAR erzeugt unter `pdf-umbenenner-bootstrap/target/` | **ja** |
| Konfigurations- und Dokumentationsbeispiele auf Konsistenz geprüft | **ja** |
| Bedienanleitung (`gui-bedienanleitung.md`) gegen reales GUI-Verhalten stichprobengeprüft | **ja ein während der Prüfung identifizierter Befund wurde unmittelbar korrigiert (siehe R3)** |
---
## Build- und Test-Ergebnisse
Ausgeführtes Kommando:
```
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make -DskipPitest=true
```
**Gesamtergebnis: BUILD SUCCESS**
**Laufzeit: 01:15 min**
| Modul | Tests | Failures | Errors | Skipped |
|---|---|---|---|---|
| `pdf-umbenenner-domain` | 227 | 0 | 0 | 0 |
| `pdf-umbenenner-application` | 455 | 0 | 0 | 0 |
| `pdf-umbenenner-adapter-in-cli` | 8 | 0 | 0 | 0 |
| `pdf-umbenenner-adapter-in-gui` | 190 | 0 | 0 | 0 |
| `pdf-umbenenner-adapter-out` | 371 | 0 | 0 | 0 |
| `pdf-umbenenner-bootstrap` (Unit) | 147 | 0 | 0 | 0 |
| `pdf-umbenenner-bootstrap` (IT) | 5 | 0 | 0 | 0 |
| **Gesamt** | **1.403** | **0** | **0** | **0** |
Erzeugtes Shaded-JAR: `pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar`
Größe: ca. 28 MB (enthält JavaFX für Windows, PDFBox, SQLite-JDBC, Log4j2 und alle Module)
---
## Offene nicht blockierende Restpunkte
Die folgenden Restpunkte wurden in der integrierten Gesamtprüfung dokumentiert und
gelten als freigabekompatibel.
### R1 ByteBuddy-Agent-Warnung bei Tests
Beim Build erscheint wiederholt `WARNING: A Java agent has been loaded dynamically
(byte-buddy-agent-1.14.12.jar)`. Der Hinweis stammt von Mockito und tritt seit dem V1.1-Stand
auf. Kein funktionaler Einfluss. Mit `-XX:+EnableDynamicAgentLoading` unterdrückbar, aber keine
Pflicht für V2.0.
### R2 GUI-Tests ohne echten JavaFX-Rendering-Pfad
Die GUI-Tests laufen unter headless JavaFX (Monocle) und prüfen View-Modell-Logik,
Zustandsübergänge und Koordinatoren-Verhalten. Das visuelle Rendering (Farbgebung der
Meldungspräfixe, Layout-Details) ist nicht automatisiert geprüft. Dies entspricht der in
CLAUDE.md definierten GUI-Teststrategie (kein TestFX über Monocle hinaus).
### R3 Bedienanleitung: Legacy-Umgebungsvariable für OpenAI-kompatibel (behoben)
**Ursprünglicher Befund:** Abschnitt 10 der `gui-bedienanleitung.md` beschrieb
`OPENAI_COMPATIBLE_API_KEY` fälschlich als Legacy-Umgebungsvariable. Tatsächlich ist
`OPENAI_COMPATIBLE_API_KEY` die primäre providerspezifische Umgebungsvariable, und die
echte Legacy-Umgebungsvariable lautet `PDF_UMBENENNER_API_KEY`.
**Status:** Korrigiert. Abschnitt 10 benennt jetzt `OPENAI_COMPATIBLE_API_KEY` als
primäre Variable und `PDF_UMBENENNER_API_KEY` als Legacy-Variable und hält fest, dass
Claude keine Legacy-Variable hat.
**Kein Release-Blocker und im V2.0-Freigabestand nicht mehr offen.**
---
## Freigabeaussage
V2.0 ist nach Prüfung fehlerfrei buildbar, vollständig nach Spezifikation umgesetzt
und als freigabefähig einzustufen. Keine Release-Blocker. Die zwei nicht blockierenden
Restpunkte (R1, R2) sind dokumentiert und können außerhalb des V2.0-Scopes adressiert
werden; R3 wurde während der finalen Prüfung unmittelbar behoben.
Der vollständige Maven-Reactor-Build ist grün (1.403 Tests, 0 Failures, 0 Errors,
0 Skipped). Alle in der integrierten Gesamtprüfung definierten Spezifikations-
Prüfpunkte gegen die Spec-Trias sind als erfüllt bestätigt. Die Dokumentation ist
vollständig und konsistent mit dem Code.
+112
View File
@@ -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.
+146
View File
@@ -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.
+986
View File
@@ -0,0 +1,986 @@
# 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
verwalten und technisch prüfen möchten.
---
## 1. Zweck und Scope der GUI
Die GUI gliedert sich in vier feste Tabs:
- **Tab 1 „Konfiguration"** Editor, Validierungsoberfläche und technische
Test-/Diagnoseoberfläche für die `.properties`-Datei.
- **Tab 2 „Verarbeitungslauf"** Start eines Batch-Laufs aus der GUI mit
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
- **Tab 3 „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).
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 18).
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
`--headless` der empfohlene Weg.
---
## 2. Startzustände
### 2.1 GUI-Standardstart
Wird die Anwendung ohne CLI-Argumente gestartet, öffnet sich die JavaFX-Desktop-GUI
**maximiert** (Vollbild).
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)
Wird beim Start eine gültige `.properties`-Datei über `--config <pfad>` angegeben,
lädt die GUI diese Datei automatisch und zeigt den Inhalt im Editor an. Der Pfad
wird im Header angezeigt.
### 2.3 Start mit `--config <pfad>` (ungültiger oder nicht vorhandener Pfad)
Zeigt der angegebene Pfad auf eine nicht vorhandene oder nicht lesbare Datei,
erscheint eine Fehlermeldung im zentralen Meldungsbereich. Danach verhält sich die
GUI so, als wäre `--config` nicht angegeben worden: Es erscheint der Willkommenstext,
und der Benutzer kann manuell eine Datei öffnen oder eine neue Konfiguration anlegen.
Im Unterschied zum headless Betrieb ist dies kein harter Startfehler die GUI
bleibt vollständig bedienbar.
### 2.4 GUI-Startfehler vor Anzeige der Oberfläche
Tritt vor der erfolgreichen Anzeige der grafischen Oberfläche ein nicht behebbarer
Fehler auf (z. B. fehlende JavaFX-Laufzeit, schwerwiegender Bootstrap-Fehler),
beendet sich die Anwendung mit Exit-Code `1`. In diesem Fall wird keine Oberfläche
angezeigt.
---
## 3. Header und Meldungsbereich
### 3.1 Header
Der Header oben im Fenster zeigt den Pfad der aktuell geladenen `.properties`-Datei.
Ist keine Datei geladen, bleibt der Pfadbereich leer oder zeigt einen entsprechenden
Hinweis.
Sobald ungespeicherte Änderungen vorliegen, erscheinen zwei visuelle Markierungen:
- ein kleines **„geändert"**-Label im Header
- ein führendes **`*`** im Fenstertitel
Diese Markierungen verschwinden, sobald die Datei gespeichert wurde oder Änderungen
verworfen werden.
### 3.2 Zentraler Meldungsbereich
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:
| Stufe | Präfix | Farbe des Präfix |
|-------|--------|------------------|
| **Info** | `Info:` | Blau |
| **Hinweis** | `Hinweis:` | Grün |
| **Warnung** | `Warnung:` | Orange |
| **Fehler** | `Fehler:` | Rot |
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
### 4.1 Neu
Erzeugt eine neue Konfiguration aus der internen Standardvorlage. Die Vorlage
enthält sinnvolle Standardwerte und beide bekannten Provider-Blöcke (Claude und
OpenAI-kompatibel). Standardmäßig ist der alphabetisch erste Provider (Claude)
als aktiver Provider vorbelegt.
Sind zum Zeitpunkt der Aktion ungespeicherte Änderungen vorhanden, erscheint
der Schutzdialog (siehe Abschnitt 8).
### 4.2 Öffnen
Öffnet einen nativen Dateidialog gefiltert auf `*.properties`-Dateien. Die
ausgewählte Datei wird geladen und im Editor angezeigt.
Enthält die Datei das ältere Legacy-Format (flache `api.*`-Schlüssel), wird sie
automatisch ins aktuelle Mehrprovider-Schema migriert. Vor der Migration wird eine
`.bak`-Sicherung der Originaldatei angelegt (siehe Abschnitt 9). Die durchgeführte
Migration wird im zentralen Meldungsbereich sichtbar gemeldet.
Sind zum Zeitpunkt der Aktion ungespeicherte Änderungen vorhanden, erscheint
der Schutzdialog (siehe Abschnitt 8).
### 4.3 Speichern
Schreibt den aktuellen Editorstand in die zuletzt geladene oder gespeicherte Datei.
Ist die Konfiguration neu und wurde noch nie gespeichert, verhält sich „Speichern"
wie „Speichern unter": Es wird ein Dateidialog geöffnet und ein Speicherpfad
erfragt.
### 4.4 Speichern unter
Öffnet einen nativen Dateidialog gefiltert auf `*.properties`-Dateien. Als
Vorschlagspfad wird `config/application.properties` relativ zum aktuellen
Arbeitsverzeichnis verwendet. Damit ist die gespeicherte Datei ohne weitere
Schritte für den nächsten headless Batch-Lauf nutzbar.
Zeigt der Dialog auf eine bereits existierende Datei, erscheint eine
Rückfrage „Datei überschreiben?". Bei Bestätigung wird vor dem Überschreiben
automatisch eine `.bak`-Sicherung angelegt (siehe Abschnitt 9).
Kommentare und Schlüsselreihenfolge der `.properties`-Datei werden beim
Speichern normalisiert.
### 4.5 Validieren
Führt eine explizite, nicht-schreibende Gesamtprüfung des aktuellen Editorzustands
durch. Die Prüfung läuft lokal ohne Netzwerkzugriff und umfasst dieselben
Regelprüfungen wie die automatische Hintergrundvalidierung, kann aber zusätzliche
lokale Prüfpunkte zusammenführen.
Die Prüfung arbeitet auf dem **aktuellen GUI-Zustand**, also auch auf ungespeicherten
Änderungen. Die Datei wird dabei **nicht** implizit gespeichert.
Ergebnisse erscheinen im zentralen Meldungsbereich und als feldnahe
Fehlermeldungen direkt unter den betroffenen Eingabefeldern.
### 4.6 Technische Tests ausführen
Führt eine umfassende technische Prüfung des aktuellen Editorzustands durch,
einschließlich provider-naher Tests mit Netzwerkzugriff. Alle Prüfpunkte werden
vollständig und gesammelt durchlaufen; die Aktion bricht bei einem Einzelfehler
nicht ab.
Geprüft werden unter anderem:
- `.properties`-Datei und Provider-Konfiguration
- Erreichbarkeit von Base-URL bzw. Endpoint
- Vorhandensein und technische Akzeptanz des API-Keys
- Abrufbarkeit der Modellliste
- Plausibilität des gewählten Modells
- Vorhandensein und Lesbarkeit der Prompt-Datei
- Quellordner (vorhanden und lesbar)
- Zielordner (vorhanden oder anlegbar und beschreibbar)
- SQLite-Datei bzw. -Pfad
Die Tests arbeiten auf dem **aktuellen GUI-Zustand** ohne implizites Speichern.
Wenn ungespeicherte Änderungen vorliegen, ist ein entsprechender Hinweis
zweckmäßig.
Bestimmte Befunde können durch korrigierende Maßnahmen behoben werden (z. B.
Zielordner anlegen, SQLite-Datei anlegen, fehlende Prompt-Datei mit einem
deutschen Standardinhalt erzeugen). Diese Korrekturen erfolgen **nicht** still im
Hintergrund, sondern erst nach Bestätigung eines gesammelten Bestätigungsdialogs
(„Folgende Korrekturen werden durchgeführt … Fortfahren?").
Die automatisch erzeugte Prompt-Datei enthält einen deutschen Standardprompt,
der ohne weitere Anpassung funktioniert. Ein Beispiel dieses Standardprompts liegt
unter [`docs/examples/prompt.txt`](../docs/examples/prompt.txt).
Nicht automatisch korrigierbar sind insbesondere: falscher API-Key,
unerreichbare Base-URL, nicht verfügbare Modellliste, sonstige externe
technische Fehler.
### 4.7 Modelle neu laden
Ruft die Modellliste des aktuell ausgewählten Providers erneut über einen
Hintergrund-Worker-Thread ab. Der gleiche Abruf wird auch automatisch bei jedem
Providerwechsel ausgelöst.
---
## 5. Feldnahe Fehlermeldungen
Direkt unter bestimmten Eingabefeldern kann die GUI kleine, rote,
deutschsprachige Hinweise einblenden, wenn der eingetragene Wert fehlerhaft oder
riskant ist. Diese feldnahen Meldungen ergänzen den zentralen Meldungsbereich
und ersetzen ihn nicht.
Feldnahe Meldungen erscheinen nach einer Validierung oder nach dem Ausführen der
technischen Tests. Sie verschwinden, sobald der Fehler behoben und eine neue
Prüfung durchgeführt wurde.
---
## 6. Automatische Hintergrundvalidierung
Eine geladene Konfiguration wird sofort beim Öffnen geprüft. Während der
Bearbeitung aktualisiert die Validierung ihre Ergebnisse kontinuierlich im
Hintergrund. Ergebnisse werden im zentralen Meldungsbereich und als feldnahe
Hinweise angezeigt.
Die automatische Validierung unterscheidet:
- **Fehler:** Der Konfigurationsstand ist nicht lauffähig.
- **Warnungen:** Die Einstellung ist technisch akzeptabel, aber riskant oder
unüblich. Beispiele: sehr hohe `max.text.characters`-Werte, ungewöhnliche
Timeouts.
- **Hinweise:** Informationen ohne Handlungsbedarf.
Warnungen und Hinweise verhindern das Speichern nicht. Vor dem Speichern eines
als **nicht lauffähig** markierten Stands erscheint jedoch eine deutlich sichtbare
Warnung im zentralen Meldungsbereich, die ausdrücklich auf mögliche Auswirkungen
auf den nächsten headless Lauf hinweist. Speichern ist dennoch erlaubt.
Wirtschaftliche Warnschwellen für `max.text.characters`:
| Wertebereich | Bewertung |
|---|---|
| bis 1.000 | unkritisch |
| 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 1520 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
### 7.1 Provider-ComboBox
Im Bereich „Provider" befindet sich eine ComboBox zur Auswahl des aktiven
Providers. In V2.0 stehen zwei Einträge zur Verfügung:
- **Claude**
- **OpenAI-kompatibel**
Nur der aktuell ausgewählte Provider-Bereich ist im Formular sichtbar. Der
verdeckte Provider-Block behält seine Daten; ein Providerwechsel löscht die
Konfiguration des anderen Blocks nicht.
### 7.2 Automatischer Modellabruf
Bei jedem Providerwechsel startet der Modellabruf automatisch auf einem
Hintergrund-Worker-Thread. Wird die Modellliste erfolgreich geladen, erscheint
eine nicht editierbare ComboBox mit den verfügbaren Modellen. Das erste Modell
der Liste ist automatisch vorbelegt.
Kann keine Modellliste abgerufen werden (z. B. wegen fehlendem oder falschem
API-Key, unerreichbarem Endpoint), erscheint statt der ComboBox ein leeres
Texteingabefeld. In diesem Fall muss der Modellname manuell eingetragen werden.
Wurde zuvor ein Modellname manuell eingetragen und wird später eine echte
Modellliste geladen, in der dieser Wert nicht vorkommt, wird der manuell
eingetragene Modellname verworfen. Es wird dann das erste Modell der Liste
vorbelegt.
---
## 8. Dirty-State und Schutzdialoge
Sobald eine geladene oder neu erzeugte Konfiguration bearbeitet wird, gilt der
Editor als „dirty" (ungespeicherte Änderungen). Zwei visuelle Markierungen
zeigen diesen Zustand an:
- Ein **`*`**-Präfix im Fenstertitel
- Ein kleines **„geändert"**-Label im Header
Vor den Aktionen „Neu", „Öffnen" und beim Schließen des Fensters prüft die GUI,
ob ungespeicherte Änderungen vorhanden sind. Ist dies der Fall, erscheint ein
Schutzdialog mit drei Optionen:
| Option | Wirkung |
|--------|---------|
| **Speichern** | Speichert die Änderungen und führt die Aktion danach aus |
| **Verwerfen** | Verwirft die Änderungen und führt die Aktion aus |
| **Abbrechen** | Bricht die Aktion ab; die Änderungen bleiben erhalten |
---
## 9. `.bak`-Sicherung beim Überschreiben und Legacy-Migration
### 9.1 Sicherung beim Überschreiben
Bevor eine bestehende `.properties`-Datei überschrieben wird, legt die GUI
automatisch eine Sicherungskopie an:
- Standardfall: `<dateiname>.bak` (im selben Verzeichnis)
- Falls `.bak` bereits existiert: `<dateiname>.bak.1`, `.bak.2`, …
- Bestehende Sicherungen werden **niemals überschrieben**.
Dieses Schema gilt sowohl für „Speichern unter" bei existierendem Ziel als auch
für die Legacy-Migration beim Öffnen.
### 9.2 Legacy-Migration
Ältere `.properties`-Dateien, die noch die flachen Schlüssel `api.baseUrl`,
`api.model`, `api.timeoutSeconds` und `api.key` verwenden, erkennt die GUI beim
Öffnen automatisch als Legacy-Format. Sie führt folgende Schritte durch:
1. `.bak`-Sicherung der Originaldatei anlegen (nach dem Schema aus 9.1)
2. Inhalt ins aktuelle Mehrprovider-Schema überführen:
- Legacy-Werte werden dem Namensraum `openai-compatible` zugeordnet
- `ai.provider.active=openai-compatible` wird ergänzt
3. Die migrierte Konfiguration wird im Editor angezeigt
Die durchgeführte Migration wird im zentralen Meldungsbereich sichtbar gemeldet,
damit der Benutzer die Änderung nachvollziehen kann.
---
## 10. API-Key-Auflösungsreihenfolge
Der API-Key eines Providers wird in folgender Priorität aufgelöst:
1. **Providerspezifische Umgebungsvariable** (höchste Priorität)
2. Für **OpenAI-kompatibel** zusätzlich: Legacy-Umgebungsvariable
`PDF_UMBENENNER_API_KEY` (aus dem früheren Einzelprovider-Stand)
3. **Property-Wert** in der `.properties`-Datei
| Provider | Providerspezifische Umgebungsvariable |
|---|---|
| Claude | `ANTHROPIC_API_KEY` |
| OpenAI-kompatibel | `OPENAI_COMPATIBLE_API_KEY` |
Für **OpenAI-kompatibel** wird zusätzlich die Legacy-Variable
`PDF_UMBENENNER_API_KEY` akzeptiert, falls die providerspezifische Variable
nicht gesetzt ist. Für Claude gibt es keine Legacy-Variable.
Die GUI zeigt unterhalb des API-Key-Felds die Herkunft des aktuell wirksamen
Schlüssels an. Greift eine Umgebungsvariable, erscheint ein Hinweis mit dem
Namen der Variablen (z. B. „Aktuell wirksam aus Umgebungsvariable
`ANTHROPIC_API_KEY`"). Ist kein Schlüssel aus keiner Quelle verfügbar, erscheint
eine Warnung.
Das API-Key-Feld ist ein normales, unmaskiertes Textfeld. Ein leeres Feld entfernt
den vorhandenen Property-Wert **nicht** stillschweigend, solange keine
Umgebungsvariable greift. In diesem Fall bleibt der bestehende Property-Wert
erhalten, und die GUI zeigt eine deutliche Warnung.
---
## 11. Pfadfelder und Datei-/Ordnerdialoge
Für die folgenden Konfigurationswerte stellt die GUI je ein Texteingabefeld
sowie einen kleinen Button zum Öffnen des nativen Dialogs bereit:
| Feld | Dialog-Typ |
|------|------------|
| Quellordner | Ordnerdialog |
| Zielordner | Ordnerdialog |
| SQLite-Datei | Dateidialog |
| Prompt-Datei | Dateidialog |
Pfade können auch direkt ins Texteingabefeld eingegeben werden, ohne den Dialog
zu nutzen.
---
## 12. Windows-Hinweise zu gemappten Laufwerken
Die GUI sowie alle zugehörigen Pfadprüfungen akzeptieren Windows-Laufwerksbuchstaben
wie `S:\`, `H:\` oder `C:\`. Gemappte Netzlaufwerke werden ausdrücklich unterstützt
und werden nicht allein wegen eines dahinterliegenden UNC-Pfads abgelehnt.
UNC-Pfade (z. B. `\\server\freigabe\pfad\`) werden ebenfalls akzeptiert, sind aber
nicht das primäre Format für den GUI-Betrieb.
Die GUI wird offiziell nur unter **Windows** unterstützt.
---
## 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 Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten |
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. 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.
+120
View File
@@ -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 |
| 19 | Fehler | Minimum ist 10 Zeichen, Start wird abgebrochen |
| 1039 | Warnung | „Titellänge unter 40 Zeichen KI-Ergebnisse können unvollständig sein, da Absender allein bereits 1520 Zeichen benötigt" |
| 4099 | OK | Normaler Betrieb, keine Meldung |
| 100120 | 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
+297
View File
@@ -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.0V2.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
+193
View File
@@ -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
+378
View File
@@ -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
+25 -6
View File
@@ -66,9 +66,9 @@ Fallback auf aktuelles Datum ist erlaubt, wenn kein belastbares Datum eindeutig
### 4.3 Titel ### 4.3 Titel
- maximal **20 Zeichen (Basistitel)** - maximal **konfigurierbare Anzahl Zeichen (Basistitel, Default 60, gültiger Bereich 10..120)**
- verständlich und eindeutig - verständlich und eindeutig
- keine Sonderzeichen außer Leerzeichen - keine Sonderzeichen außer Leerzeichen, Bindestrichen, Punkten, Kommas und Ampersands
--- ---
@@ -87,7 +87,7 @@ Bei Namenskonflikten:
Regel: Regel:
- 20 Zeichen gelten nur für den Basistitel - die konfigurierte maximale Titellänge gilt nur für den Basistitel
- Suffix wird zusätzlich ergänzt - Suffix wird zusätzlich ergänzt
--- ---
@@ -192,7 +192,7 @@ Ein Ergebnis ist korrekt, wenn:
- Format stimmt - Format stimmt
- Datum korrekt ist - Datum korrekt ist
- Titel max. 20 Zeichen hat - Titel die konfigurierte maximale Länge einhält
- Dubletten korrekt behandelt wurden - Dubletten korrekt behandelt wurden
- Begründung vorhanden ist - Begründung vorhanden ist
- Ergebnis reproduzierbar ist - Ergebnis reproduzierbar ist
@@ -201,12 +201,31 @@ Ein Ergebnis ist korrekt, wenn:
## 14. Nicht-Ziele ## 14. Nicht-Ziele
- keine manuelle Nachbearbeitung - kein manueller Verarbeitungslauf durch den Benutzer (die KI-Verarbeitungskette
- keine Benutzerinteraktion läuft ausschließlich automatisiert)
- keine Inhaltsänderung von Dokumenten - keine Inhaltsänderung von Dokumenten
--- ---
## 14a. Manuelle Korrektur des Dateinamens nach automatischer Verarbeitung
Nach Abschluss eines automatisierten Verarbeitungslaufs kann der Benutzer den von der
KI vorgeschlagenen Dateinamen der Zieldatei **manuell korrigieren**.
Verbindliche Regeln:
- Die Korrektur ist **optional** und ersetzt keinen erneuten KI-Aufruf.
- Der geänderte Dateiname muss denselben Formatregeln genügen wie ein automatisch
erzeugter Name (`YYYY-MM-DD - Titel.pdf`, zulässige Sonderzeichen, Titellänge).
- Namenskonflikte im Zielordner werden durch Dubletten-Suffix aufgelöst
(analog zur automatischen Verarbeitung).
- Die Umbenennung ist **atomar**: entweder Dateisystem und Datenbank werden
konsistent aktualisiert, oder die Aktion wird vollständig zurückgerollt.
- Die Quelldatei bleibt unverändert.
- Ein manuell korrigierter Dateiname wird in der Versuchshistorie persistiert.
---
## 15. Qualitätsanforderungen ## 15. Qualitätsanforderungen
- deterministisches Verhalten - deterministisches Verhalten
+13 -5
View File
@@ -55,8 +55,8 @@ YYYY-MM-DD - Titel(2).pdf
``` ```
Dabei gilt: Dabei gilt:
- die **20 Zeichen** beziehen sich nur auf den **Basistitel** - die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel**
- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen - das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge
- die Quelldatei wird **nie** überschrieben oder verändert - die Quelldatei wird **nie** überschrieben oder verändert
--- ---
@@ -133,8 +133,8 @@ Beispiel:
#### Adapter Out #### Adapter Out
Enthält technische Implementierungen der Outbound-Ports, insbesondere: Enthält technische Implementierungen der Outbound-Ports, insbesondere:
- Dateisystem - Dateisystem (inkl. `FilesystemTargetFileRenameAdapter` für atomare Zieldatei-Umbenennung)
- PDFBox - PDFBox (Textauslese sowie direktes Seitenrendering für die GUI-Vorschau via `PDFRenderer.renderImageWithDPI`)
- SQLite - SQLite
- KI-HTTP-Clients (eine Implementierung je unterstütztem Provider, siehe Abschnitt 11) - KI-HTTP-Clients (eine Implementierung je unterstütztem Provider, siehe Abschnitt 11)
- Properties-/Umgebungs-Konfiguration - Properties-/Umgebungs-Konfiguration
@@ -204,12 +204,19 @@ Verbindlich zweckmäßige Outbound-Ports:
- `FingerprintPort` - `FingerprintPort`
- `ProcessedDocumentRepository` - `ProcessedDocumentRepository`
- `AiNamingPort` - `AiNamingPort`
- `TargetFileRenamePort`
- `ConfigurationPort` - `ConfigurationPort`
- `RunLockPort` - `RunLockPort`
- `ClockPort` - `ClockPort`
Der `AiNamingPort` bleibt **provider-neutral**. Er kennt weder OpenAI- noch Anthropic-spezifische Typen, Header, URLs oder Antwortformate. Provider-spezifische Details (Endpunkt, Authentifizierung, Request-/Response-Format) leben ausschließlich in den jeweiligen Adapter-Out-Implementierungen. Der `AiNamingPort` bleibt **provider-neutral**. Er kennt weder OpenAI- noch Anthropic-spezifische Typen, Header, URLs oder Antwortformate. Provider-spezifische Details (Endpunkt, Authentifizierung, Request-/Response-Format) leben ausschließlich in den jeweiligen Adapter-Out-Implementierungen.
Der `TargetFileRenamePort` kapselt die atomare Umbenennung einer bereits kopierten Zieldatei.
Er wird vom Use Case `ManualFileRenameUseCase` genutzt und ist durch
`FilesystemTargetFileRenameAdapter` implementiert. Der Port-Vertrag enthält keine
`Path`- oder NIO-Typen in öffentlichen Signaturen; er arbeitet ausschließlich mit
Domain-Typen und String-basierten Dateinamen.
### 6.3 Logging ### 6.3 Logging
Logging ist **kein fachlicher Port**. Logging ist technische Infrastruktur. Logging ist **kein fachlicher Port**. Logging ist technische Infrastruktur.
@@ -290,7 +297,7 @@ Der Titel muss technisch diese Regeln erfüllen:
- Deutsch - Deutsch
- verständlich - verständlich
- eindeutig genug für den Dokumentkontext - eindeutig genug für den Dokumentkontext
- maximal **20 Zeichen** als Basistitel - maximal die **konfigurierte Titellänge** als Basistitel (Default 60, gültiger Bereich 10..120)
- keine unzulässigen Windows-Dateinamenzeichen - keine unzulässigen Windows-Dateinamenzeichen
- keine generischen Platzhalter wie z. B. `Dokument`, `Datei`, `Scan`, `PDF` - keine generischen Platzhalter wie z. B. `Dokument`, `Datei`, `Scan`, `PDF`
- Eigennamen bleiben unverändert - Eigennamen bleiben unverändert
@@ -532,6 +539,7 @@ Verbindlich zweckmäßige Parameter:
- `max.retries.transient` - `max.retries.transient`
- `max.pages` - `max.pages`
- `max.text.characters` - `max.text.characters`
- `max.title.length`
- `prompt.template.file` - `prompt.template.file`
Pro unterstützter Provider-Familie existiert ein eigener Parameter-Namensraum mit zweckmäßig mindestens: Pro unterstützter Provider-Familie existiert ein eigener Parameter-Namensraum mit zweckmäßig mindestens:
+361
View File
@@ -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.
+216
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@
<parent> <parent>
<groupId>de.gecheckt</groupId> <groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId> <artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>${revision}</version>
</parent> </parent>
<artifactId>pdf-umbenenner-adapter-in-cli</artifactId> <artifactId>pdf-umbenenner-adapter-in-cli</artifactId>
<packaging>jar</packaging> <packaging>jar</packaging>
+52 -9
View File
@@ -5,7 +5,7 @@
<parent> <parent>
<groupId>de.gecheckt</groupId> <groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId> <artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>${revision}</version>
</parent> </parent>
<artifactId>pdf-umbenenner-adapter-in-gui</artifactId> <artifactId>pdf-umbenenner-adapter-in-gui</artifactId>
<packaging>jar</packaging> <packaging>jar</packaging>
@@ -39,6 +39,31 @@
<artifactId>javafx-controls</artifactId> <artifactId>javafx-controls</artifactId>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<!-- JavaFX-Swing-Interop: wird für SwingFXUtils.toFXImage (BufferedImage -> FX Image) benötigt -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-swing</artifactId>
<version>21.0.2</version>
<classifier>win</classifier>
</dependency>
<!-- PDF-Vorschau: PDFBox für direktes Rendering einzelner Seiten in BufferedImages -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
</dependency>
<!-- JBIG2-Codec für PDF-Bilddecodierung -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>jbig2-imageio</artifactId>
<version>3.0.4</version>
</dependency>
<!-- JPEG2000-Codec für erweiterte PDF-Bilddecodierung -->
<dependency>
<groupId>com.github.jai-imageio</groupId>
<artifactId>jai-imageio-jpeg2000</artifactId>
<version>1.4.0</version>
</dependency>
<!-- Logging --> <!-- Logging -->
<dependency> <dependency>
@@ -47,6 +72,19 @@
</dependency> </dependency>
<!-- Test dependencies --> <!-- Test dependencies -->
<!--
log4j-core on the test classpath provides the logging implementation for
tests that instantiate production classes using LogManager.getLogger.
Without it, Log4j2 falls back to SimpleLogger during test execution and
prints "Log4j2 could not find a logging implementation" at test start.
The production classpath is unaffected; log4j-core is supplied by the
bootstrap module in the shaded runtime JAR.
-->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
@@ -62,6 +100,11 @@
<artifactId>mockito-junit-jupiter</artifactId> <artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<!-- <!--
Monocle: headless JavaFX platform for GUI smoke tests. Monocle: headless JavaFX platform for GUI smoke tests.
Provides the Glass platform implementation that runs JavaFX without a Provides the Glass platform implementation that runs JavaFX without a
@@ -87,8 +130,8 @@
prism.order=sw enables software rendering (no GPU required); prism.order=sw enables software rendering (no GPU required);
prism.text=t2k selects the T2K text rasterizer (headless-safe); prism.text=t2k selects the T2K text rasterizer (headless-safe);
java.awt.headless=true signals headless mode to AWT/Swing interop layers. java.awt.headless=true signals headless mode to AWT/Swing interop layers.
The add-opens args are required for JavaFX internal access patterns used Note: module-opening arguments for javafx.graphics are no longer required.
by Monocle and the Platform.startup API in Java 21 module context. Modern JavaFX (21.x) with Monocle on Java 21 works without explicit module opening.
--> -->
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
@@ -101,8 +144,6 @@
-Dprism.order=sw -Dprism.order=sw
-Dprism.text=t2k -Dprism.text=t2k
-Djava.awt.headless=true -Djava.awt.headless=true
--add-opens=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED
--add-opens=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED
</argLine> </argLine>
</configuration> </configuration>
</plugin> </plugin>
@@ -164,11 +205,13 @@
</goals> </goals>
<configuration> <configuration>
<!-- <!--
GUI adapter mutation thresholds are intentionally low: the JavaFX GUI adapter: PIT is skipped entirely. The JavaFX Application lifecycle
Application lifecycle requires a display or headless Monocle runtime cannot be meaningfully mutation-tested without a running display or
which is introduced in a later work package. Once Monocle smoke tests Monocle runtime, and the remaining testable surface is too small to
are in place, these thresholds will be raised. produce useful mutation scores. Mutation analysis is deferred until
GUI coverage matures.
--> -->
<skip>true</skip>
<coverageThreshold>0</coverageThreshold> <coverageThreshold>0</coverageThreshold>
<mutationThreshold>0</mutationThreshold> <mutationThreshold>0</mutationThreshold>
</configuration> </configuration>
@@ -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,11 +2,11 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Optional; import java.util.Optional;
import javafx.application.Application;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import javafx.application.Application;
/** /**
* Entry point for the JavaFX desktop GUI inbound adapter. * Entry point for the JavaFX desktop GUI inbound adapter.
* <p> * <p>
@@ -5,6 +5,11 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
*/ */
public class GuiConfigurationLoadException extends RuntimeException { public class GuiConfigurationLoadException extends RuntimeException {
/**
*
*/
private static final long serialVersionUID = 5039061738684738963L;
/** /**
* Creates a new load exception. * Creates a new load exception.
* *
@@ -8,6 +8,11 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
*/ */
public class GuiConfigurationWriteException extends RuntimeException { public class GuiConfigurationWriteException extends RuntimeException {
/**
*
*/
private static final long serialVersionUID = -6970750036865888915L;
/** /**
* Creates an exception with the given message. * Creates an exception with the given message.
* *
@@ -0,0 +1,287 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionReport;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
import javafx.application.Platform;
/**
* Koordiniert den gesammelten Bestätigungsdialog und die anschließende Ausführung
* schreibender Korrekturmaßnahmen nach einem technischen Gesamttest.
* <p>
* Der Koordinator empfängt einen {@link TechnicalTestReport}, prüft ob korrigierbare
* Befunde vorliegen, leitet daraus einen {@link CorrectionPlan} ab, zeigt dem Benutzer
* einen gesammelten Bestätigungsdialog und führt die Korrekturen bei Bestätigung über
* den {@link CorrectionExecutionService} auf einem Hintergrund-Worker-Thread aus.
* Ergebnisse werden in die geteilte {@code pendingMessages}-Liste eingehängt.
*
* <h2>Ablauf</h2>
* <ol>
* <li>Bericht erhält: prüfen ob {@code hasCorrectableFindings()}.</li>
* <li>Wenn keine korrigierbaren Befunde: kein Dialog, keine Aktion.</li>
* <li>Wenn korrigierbare Befunde: {@link CorrectionPlan} ableiten.</li>
* <li>Dialog auf FX-Thread anzeigen.</li>
* <li>Bei Bestätigung: Korrekturen auf Worker-Thread ausführen.</li>
* <li>Ergebnisse via {@code Platform.runLater} auf FX-Thread zurückführen.</li>
* <li>Meldungen in {@code pendingMessages} einhängen (Replace-Semantik).</li>
* </ol>
*
* <h2>Threading-Kontrakt</h2>
* <p>
* {@link #offerCorrections(TechnicalTestReport)} muss auf dem JavaFX Application Thread
* aufgerufen werden. I/O (Ausführung der Korrekturen) läuft auf einem dedizierten
* Daemon-Hintergrund-Thread. UI-Updates erfolgen ausschließlich via
* {@code Platform.runLater}.
*
* <h2>Keine stillen Korrekturen</h2>
* <p>
* Ohne ausdrückliche Benutzerbestätigung werden keine schreibenden Änderungen ausgeführt.
* Bei Dialog-Abbruch bleibt der Zustand unverändert.
*
* <h2>Anti-Scope</h2>
* <p>
* Dieser Koordinator führt keine Provider-nahen Korrekturen, keine Änderung fachlich
* riskanter Werte und keine automatischen Laufstarts durch.
*/
public final class GuiCorrectionDialogCoordinator {
/** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
static final String SOURCE_TAG = "Korrekturen";
private static final Logger LOG = LogManager.getLogger(GuiCorrectionDialogCoordinator.class);
private final CorrectionExecutionService correctionExecutionService;
private final List<GuiMessageEntry> pendingMessages;
private final Consumer<Void> refreshCallback;
/**
* Funktion, die dem Benutzer den Bestätigungsdialog zeigt.
* <p>
* Erhält den {@link ConfirmationDialogContent} und gibt {@code true} zurück, wenn der
* Benutzer Fortfahren" wählt, andernfalls {@code false}. Standard: echter Alert.
* Paket-privat für Test-Substitution.
*/
Function<ConfirmationDialogContent, Boolean> dialogSupplier;
/**
* Factory für den Hintergrund-Worker-Thread.
* Standard: Daemon-Thread namens {@code gui-correction-worker}.
* Paket-privat für Test-Substitution.
*/
Function<Runnable, Thread> correctionThreadFactory;
/**
* Verbraucher zur Rückführung des Ergebnisses auf den FX-Thread.
* Standard: {@code Platform.runLater}. Paket-privat für Test-Substitution.
*/
java.util.function.Consumer<Runnable> resultDelivery;
/**
* Erstellt einen neuen Koordinator.
*
* @param correctionExecutionService Service für die Ausführung von Korrekturen; darf nicht {@code null} sein
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
* @param refreshCallback Callback nach Anwendung der Ergebnisse (z. B. View-Aktualisierung);
* darf nicht {@code null} sein
* @throws NullPointerException wenn einer der Parameter {@code null} ist
*/
public GuiCorrectionDialogCoordinator(CorrectionExecutionService correctionExecutionService,
List<GuiMessageEntry> pendingMessages,
Consumer<Void> refreshCallback) {
this.correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
"correctionExecutionService must not be null");
this.pendingMessages = Objects.requireNonNull(pendingMessages,
"pendingMessages must not be null");
this.refreshCallback = Objects.requireNonNull(refreshCallback,
"refreshCallback must not be null");
this.dialogSupplier = this::showConfirmationDialog;
this.correctionThreadFactory = task -> {
Thread t = new Thread(task, "gui-correction-worker");
t.setDaemon(true);
return t;
};
this.resultDelivery = Platform::runLater;
}
/**
* Prüft den Bericht auf korrigierbare Befunde, zeigt bei Bedarf den Bestätigungsdialog
* und führt die Korrekturen nach Bestätigung asynchron aus.
* <p>
* Wenn der Bericht keine korrigierbaren Befunde enthält ({@code hasCorrectableFindings()
* == false}), wird kein Dialog angezeigt und keine Aktion ausgeführt.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
*/
public void offerCorrections(TechnicalTestReport report) {
Objects.requireNonNull(report, "report must not be null");
if (!report.hasCorrectableFindings()) {
LOG.debug("Gesamttest: Keine korrigierbaren Befunde kein Bestätigungsdialog.");
return;
}
CorrectionPlan plan = report.deriveCorrectionPlan();
if (!plan.hasCorrections()) {
LOG.debug("Gesamttest: Korrekturplan ist leer kein Bestätigungsdialog.");
return;
}
LOG.info("Gesamttest: {} korrigierbare Befunde. Bestätigungsdialog wird angezeigt.", plan.size());
ConfirmationDialogContent dialogContent = ConfirmationDialogContent.fromPlan(plan);
boolean confirmed = dialogSupplier.apply(dialogContent);
if (!confirmed) {
LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen abgelehnt. Keine Änderungen.");
return;
}
LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen bestätigt. Ausführung startet.");
Runnable task = () -> {
CorrectionExecutionReport executionReport = correctionExecutionService.execute(plan);
resultDelivery.accept(() -> {
applyResult(executionReport);
refreshCallback.accept(null);
});
};
Thread worker = correctionThreadFactory.apply(task);
worker.start();
}
/**
* Wendet das Ergebnis der Korrekturausführung auf die geteilte Nachrichtenliste an.
* <p>
* Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jedes
* {@link CorrectionOutcome} einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
* angehängt.
* <p>
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
*
* @param report das Ausführungsergebnis; darf nicht {@code null} sein
*/
private void applyResult(CorrectionExecutionReport report) {
// Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
long appliedCount = 0;
long failedCount = 0;
long notAttemptedCount = 0;
for (CorrectionOutcome outcome : report.outcomes()) {
switch (outcome) {
case CorrectionOutcome.Applied applied -> {
pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.INFO,
"Korrektur angewendet: " + applied.suggestion().descriptionForUser()
+ " " + applied.message(),
SOURCE_TAG));
appliedCount++;
LOG.info("Korrektur angewendet: {} {}", applied.suggestion().descriptionForUser(),
applied.message());
}
case CorrectionOutcome.Failed failed -> {
pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.ERROR,
"Korrektur fehlgeschlagen: " + failed.suggestion().descriptionForUser()
+ " (" + failed.errorMessage() + ")",
SOURCE_TAG));
failedCount++;
LOG.warn("Korrektur fehlgeschlagen: {} {}", failed.suggestion().descriptionForUser(),
failed.errorMessage());
}
case CorrectionOutcome.NotAttempted notAttempted -> {
pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.HINT,
"Korrektur nicht durchgeführt: " + notAttempted.suggestion().descriptionForUser()
+ " " + notAttempted.reason(),
SOURCE_TAG));
notAttemptedCount++;
LOG.info("Korrektur nicht durchgeführt: {} {}", notAttempted.suggestion().descriptionForUser(),
notAttempted.reason());
}
}
}
// Zusammenfassung
String summary = "Korrekturausführung abgeschlossen: "
+ appliedCount + " angewendet, "
+ failedCount + " fehlgeschlagen, "
+ notAttemptedCount + " nicht versucht.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
LOG.info("Korrekturausführung abgeschlossen: {} angewendet, {} fehlgeschlagen, {} nicht versucht.",
appliedCount, failedCount, notAttemptedCount);
}
/**
* Zeigt den echten JavaFX-Bestätigungsdialog und gibt die Benutzerentscheidung zurück.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden. Standard-Fokus liegt auf
* dem Abbrechen-Button (kein versehentliches Bestätigen durch Enter).
*
* @param content der Dialoginhalt; darf nicht {@code null} sein
* @return {@code true} wenn der Benutzer Fortfahren" wählt, sonst {@code false}
*/
private boolean showConfirmationDialog(ConfirmationDialogContent content) {
javafx.scene.control.ButtonType proceedButton =
new javafx.scene.control.ButtonType("Fortfahren",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
javafx.scene.control.ButtonType cancelButton =
new javafx.scene.control.ButtonType("Abbrechen",
javafx.scene.control.ButtonBar.ButtonData.CANCEL_CLOSE);
javafx.scene.control.Alert alert = new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.CONFIRMATION);
alert.setTitle(content.title());
alert.setHeaderText(content.introText());
StringBuilder correctionListText = new StringBuilder();
for (String line : content.correctionLines()) {
correctionListText.append("").append(line).append("\n");
}
correctionListText.append("\nFortfahren?");
alert.setContentText(correctionListText.toString());
alert.getButtonTypes().setAll(proceedButton, cancelButton);
// Standard-Fokus: Abbrechen
javafx.scene.Node cancelNode = alert.getDialogPane().lookupButton(cancelButton);
if (cancelNode instanceof javafx.scene.control.Button btn) {
btn.setDefaultButton(true);
}
javafx.scene.Node proceedNode = alert.getDialogPane().lookupButton(proceedButton);
if (proceedNode instanceof javafx.scene.control.Button btn) {
btn.setDefaultButton(false);
}
java.util.Optional<javafx.scene.control.ButtonType> result = alert.showAndWait();
return result.isPresent() && result.get() == proceedButton;
}
/**
* Gibt eine unveränderliche Momentaufnahme der aktuell ausstehenden Nachrichten zurück.
* <p>
* Ausschließlich für Tests gedacht.
*
* @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
*/
public List<GuiMessageEntry> pendingMessagesSnapshot() {
return List.copyOf(pendingMessages);
}
}
@@ -0,0 +1,303 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
import javafx.application.Platform;
/**
* Coordinates asynchronous model catalogue retrieval for the GUI provider section.
* <p>
* This coordinator is responsible for:
* <ul>
* <li>Triggering a background HTTP call via {@link AiModelCatalogPort} on a dedicated
* daemon thread named {@code gui-model-catalog}.</li>
* <li>Returning the result to the JavaFX Application Thread via {@code Platform.runLater}.</li>
* <li>Updating the per-provider {@link GuiModelFieldContainer} to show either a
* non-editable {@code ComboBox} (success) or a manual text field (all other cases).</li>
* <li>Appending a {@link GuiMessageEntry} to the supplied pending-messages list for each
* completed retrieval attempt, so later GUI layers can display the result.</li>
* </ul>
* <p>
* Parallele Abrufanfragen (z.&nbsp;B. durch schnellen Provider-Wechsel oder mehrfaches Klicken
* auf Modelle neu laden") werden durch einen Generationszähler entschärft: Jede neue Anfrage
* erhöht den Zähler. Wenn das Ergebnis eines Hintergrund-Threads auf dem JavaFX-Thread
* verarbeitet wird, prüft der Coordinator, ob die Generationsnummer noch aktuell ist. Veraltete
* Ergebnisse (aus einer früheren Anfrage) werden verworfen, sodass stets nur das Ergebnis der
* jüngsten Anfrage in die Meldungsliste und die Feldcontainer geschrieben wird.
* <p>
* The worker thread factory is injectable so tests can supply a synchronous or latch-guarded
* executor without spinning a real OS thread.
* <p>
* This class is not thread-safe by itself. All methods intended to mutate GUI state must be
* called on the JavaFX Application Thread. Background threads only interact through
* {@code Platform.runLater}.
*/
public final class GuiModelCatalogCoordinator {
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
/** Default timeout used when no timeout is configured in the provider state. */
static final int DEFAULT_TIMEOUT_SECONDS = 10;
private final AiModelCatalogPort modelCatalogPort;
private final List<GuiMessageEntry> pendingMessages;
/**
* Factory for the background worker thread. Package-private to allow test substitution.
* The default creates a daemon thread named {@code gui-model-catalog}.
*/
Function<Runnable, Thread> modelCatalogThreadFactory;
/** Per-provider field containers; populated by the workspace when it builds provider blocks. */
private final Map<AiProviderFamily, GuiModelFieldContainer> fieldContainers =
new ConcurrentHashMap<>();
/**
* Generationszähler zur Erkennung veralteter Abruf-Ergebnisse.
* Wird bei jeder neuen Anfrage in {@link #triggerModelRetrieval} atomar erhöht.
* Hintergrund-Threads erfassen die Generation beim Start; auf dem JavaFX-Thread wird
* das Ergebnis verworfen, wenn die gespeicherte Generation nicht mehr aktuell ist.
*/
private final AtomicLong retrievalGeneration = new AtomicLong(0);
/**
* Consumer that delivers the retrieval result. In production this wraps the call in
* {@code Platform.runLater}. In tests it can be replaced with a direct call so the result
* is applied immediately on the worker thread without needing an FX queue drain.
* Package-private to allow test substitution.
*/
java.util.function.Consumer<Runnable> resultDelivery = Platform::runLater;
/**
* Optional callback invoked on the JavaFX Application Thread after each retrieval result has
* been applied. The workspace uses this hook to refresh the central message area and field-error
* labels without coupling the coordinator to the workspace implementation.
* Package-private to allow substitution in tests.
*/
Runnable postResultCallback = () -> { };
/**
* Creates a coordinator backed by the given catalogue port and shared message list.
*
* @param modelCatalogPort port used for background HTTP calls; must not be {@code null}
* @param pendingMessages mutable list to append result messages to; must not be {@code null}
*/
public GuiModelCatalogCoordinator(AiModelCatalogPort modelCatalogPort,
List<GuiMessageEntry> pendingMessages) {
this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
"modelCatalogPort must not be null");
this.pendingMessages = Objects.requireNonNull(pendingMessages,
"pendingMessages must not be null");
this.modelCatalogThreadFactory = task -> {
Thread t = new Thread(task, "gui-model-catalog");
t.setDaemon(true);
return t;
};
}
/**
* Registers a {@link GuiModelFieldContainer} for the given provider family.
* <p>
* Must be called on the JavaFX Application Thread before the first retrieval is triggered.
*
* @param family the provider family this container belongs to; must not be {@code null}
* @param container the container to register; must not be {@code null}
*/
public void registerFieldContainer(AiProviderFamily family, GuiModelFieldContainer container) {
Objects.requireNonNull(family, "family must not be null");
Objects.requireNonNull(container, "container must not be null");
fieldContainers.put(family, container);
}
/**
* Triggers an asynchronous model catalogue retrieval for the given provider family.
* <p>
* The retrieval is performed on a background worker thread. The result is delivered back
* to the JavaFX Application Thread via {@code Platform.runLater}. The registered
* {@link GuiModelFieldContainer} for the provider is updated accordingly, and a
* {@link GuiMessageEntry} is appended to the pending-messages list.
* <p>
* If no field container is registered for the provider, the call is a no-op.
* <p>
* Must be called on the JavaFX Application Thread.
*
* @param family the provider family to retrieve models for; must not be {@code null}
* @param providerState the current editor state for the provider; must not be {@code null}
*/
public void triggerModelRetrieval(AiProviderFamily family,
GuiProviderConfigurationState providerState) {
Objects.requireNonNull(family, "family must not be null");
Objects.requireNonNull(providerState, "providerState must not be null");
GuiModelFieldContainer container = fieldContainers.get(family);
if (container == null) {
LOG.debug("GUI-Modellabruf: Kein Feld-Container für Provider '{}' registriert übersprungen.",
family.getIdentifier());
return;
}
// Capture the current manual value before starting the background call.
String previousManualValue = container.currentModelValue();
// Build the request from the current editor state.
ModelCatalogRequest request = buildRequest(family, providerState);
// Generationsnummer erhöhen laufende Hintergrund-Threads mit einer älteren
// Generationsnummer verwerfen ihr Ergebnis, sobald sie auf dem FX-Thread ankommen.
long currentGeneration = retrievalGeneration.incrementAndGet();
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet (Generation {}).",
family.getIdentifier(), currentGeneration);
Runnable task = () -> {
ModelCatalogResult result = modelCatalogPort.fetchAvailableModels(request);
resultDelivery.accept(() -> {
// Veraltetes Ergebnis verwerfen, wenn inzwischen eine neuere Anfrage gestartet wurde.
if (retrievalGeneration.get() != currentGeneration) {
LOG.debug("GUI-Modellabruf: Ergebnis für Provider '{}' verworfen"
+ " (Generation {} ist nicht mehr aktuell).",
family.getIdentifier(), currentGeneration);
return;
}
applyResult(family, container, result, previousManualValue);
postResultCallback.run();
});
};
Thread worker = modelCatalogThreadFactory.apply(task);
worker.start();
}
/**
* Applies the result of a completed model catalogue retrieval to the field container and
* appends a message entry to the pending-messages list.
* <p>
* Must only be called on the JavaFX Application Thread (via {@code Platform.runLater}).
*
* @param family the provider family that was queried; must not be {@code null}
* @param container the field container to update; must not be {@code null}
* @param result the retrieval result; must not be {@code null}
* @param previousManualValue the model value that was in the text field before the call
*/
private void applyResult(AiProviderFamily family,
GuiModelFieldContainer container,
ModelCatalogResult result,
String previousManualValue) {
// Remove any previous message entries from an earlier retrieval so messages do not
// accumulate across repeated triggers of the same retrieval action.
pendingMessages.removeIf(msg -> "Modellabruf".equals(msg.source().orElse("")));
String displayName = displayNameFor(family);
switch (result) {
case ModelCatalogResult.Success success -> {
List<String> models = success.models();
container.applyModelList(models, previousManualValue);
String message = "Modellliste für " + displayName + " geladen ("
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, "Modellabruf"));
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
}
case ModelCatalogResult.EmptyList emptyList -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Provider " + displayName
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf"));
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
}
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
+ ". Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, "Modellabruf"));
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
}
case ModelCatalogResult.TechnicalFailure failure -> {
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
+ "). Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, "Modellabruf"));
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
message, failure.errorDetail(), family.getIdentifier());
}
}
}
/**
* Builds a {@link ModelCatalogRequest} from the current provider editor state.
* <p>
* Missing or blank values are passed as {@code Optional.empty()} so the adapter can apply
* its own defaults or return {@link ModelCatalogResult.IncompleteConfiguration} if required
* values are absent.
*
* @param family the target provider family; must not be {@code null}
* @param providerState the current provider editor state; must not be {@code null}
* @return a new request; never {@code null}
*/
private static ModelCatalogRequest buildRequest(AiProviderFamily family,
GuiProviderConfigurationState providerState) {
Optional<String> baseUrl = Optional.ofNullable(providerState.baseUrl())
.filter(s -> !s.isBlank());
Optional<String> apiKey = Optional.ofNullable(providerState.apiKey())
.map(keyState -> keyState.propertyValue())
.filter(s -> !s.isBlank());
int timeout = DEFAULT_TIMEOUT_SECONDS;
String timeoutStr = providerState.timeoutSeconds();
if (timeoutStr != null && !timeoutStr.isBlank()) {
try {
int parsed = Integer.parseInt(timeoutStr.trim());
if (parsed > 0) {
timeout = parsed;
}
} catch (NumberFormatException ignored) {
// Use default.
}
}
return new ModelCatalogRequest(family.getIdentifier(), baseUrl, apiKey, timeout);
}
/**
* Returns a human-readable display name for the given provider family.
*
* @param family the provider family; must not be {@code null}
* @return the display name; never {@code null}
*/
private static String displayNameFor(AiProviderFamily family) {
return switch (family) {
case CLAUDE -> "Claude";
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
};
}
/**
* Returns an unmodifiable snapshot of the pending messages collected so far.
* <p>
* This method is intended for tests that need to inspect the message list after
* a retrieval completes.
*
* @return unmodifiable list of pending messages; never {@code null}
*/
public List<GuiMessageEntry> pendingMessagesSnapshot() {
return List.copyOf(pendingMessages);
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -2,29 +2,114 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalDocumentContextPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.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.technicaltest.CorrectionExecutionService;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/** /**
* Immutable startup data for the GUI adapter. * Immutable startup data for the GUI adapter.
* <p> * <p>
* Carries the initial editor state, the optional startup notice, the file-loading callback * Carries the initial editor state, the optional startup notice, the file-loading callback,
* and the file-writing callback that the workspace uses for native save actions. * the file-writing callback that the workspace uses for native save actions, the
* {@link AiModelCatalogPort} used to retrieve available AI model lists on demand, the
* {@link ApiKeyResolutionPort} used by the editor validation to determine the effective
* API key provenance from environment variables, the {@link ProviderTechnicalTestService}
* used to execute provider-specific technical checks, the {@link PathCheckPort}
* used to verify filesystem path accessibility for configuration values, the
* {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, the
* {@link CorrectionExecutionService} used to execute corrective actions after a
* technical test run has been confirmed by the user, the {@link GuiBatchRunLauncher} used
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
* reset the persistence status of selected documents, and the
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI,
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
* folder for documents that have not yet been successfully processed, and
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
* context for documents that were skipped in the current run, 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.
*/ */
public record GuiStartupContext( public record GuiStartupContext(
GuiConfigurationEditorState initialState, GuiConfigurationEditorState initialState,
Optional<String> startupNotice, Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader, GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter) { GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory) {
/** /**
* Creates a startup context. * Creates a fully wired startup context.
* *
* @param initialState initial editor state; must not be {@code null} * @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty * @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null} * @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null} * @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run for selected
* documents; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
* documents; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
* must not be {@code null}
* @param manualFileCopyPort bridge that copies a source file to the target folder for
* documents that have not yet been successfully processed;
* must not be {@code null}
* @param historicalDocumentContextPort bridge that resolves the historical processing context
* for skipped documents; must not be {@code null}
* @param applicationVersion resolved application version string shown in the status
* bar; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
* {@code null} sein
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
* darf nicht {@code null} sein
*/ */
public GuiStartupContext { public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null"); initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -33,19 +118,335 @@ public record GuiStartupContext(
"configurationFileLoader must not be null"); "configurationFileLoader must not be null");
configurationFileWriter = Objects.requireNonNull(configurationFileWriter, configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
"configurationFileWriter must not be null"); "configurationFileWriter must not be null");
modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
"modelCatalogPort must not be null");
apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
"apiKeyResolutionPort must not be null");
providerTechnicalTestService = Objects.requireNonNull(providerTechnicalTestService,
"providerTechnicalTestService must not be null");
pathCheckPort = Objects.requireNonNull(pathCheckPort,
"pathCheckPort must not be null");
technicalTestOrchestrator = Objects.requireNonNull(technicalTestOrchestrator,
"technicalTestOrchestrator must not be null");
correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
"correctionExecutionService must not be null");
batchRunLauncher = Objects.requireNonNull(batchRunLauncher,
"batchRunLauncher must not be null");
miniRunLauncher = Objects.requireNonNull(miniRunLauncher,
"miniRunLauncher must not be null");
resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
"resetDocumentStatusPort must not be null");
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
"manualFileRenamePort must not be null");
manualFileCopyPort = Objects.requireNonNull(manualFileCopyPort,
"manualFileCopyPort must not be null");
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
"historicalDocumentContextPort must not be null");
// Null-Fallback für Testumgebungen ohne gepacktes JAR
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
"historyOverviewPort must not be null");
historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
"historyDetailsPort must not be null");
historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
"historyResetDocumentStatusPort must not be null");
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
"deleteDocumentHistoryPort must not be null");
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
"promptEditorPortFactory must not be null");
} }
/** /**
* Creates a blank startup context with no loader or writer side effects. * 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>
* This is safe for environments where no Bootstrap wiring is present, such as isolated
* GUI tests.
* *
* @param startupNotice optional startup notice; {@code null} becomes empty * @param startupNotice optional startup notice; {@code null} becomes empty
* @return a startup context for the unloaded editor start * @return a startup context for the unloaded editor start
*/ */
public static GuiStartupContext blank(Optional<String> startupNotice) { public static GuiStartupContext blank(Optional<String> startupNotice) {
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort noOpCatalogPort =
request -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
.ModelCatalogResult.IncompleteConfiguration(
request.providerIdentifier(),
"Kein Modellkatalog in diesem Startkontext verfügbar.");
ApiKeyResolutionPort noOpApiKeyPort = (family, propertyValue) -> EffectiveApiKeyDescriptor.absent();
ProviderTechnicalTestService noOpTestService =
new ProviderTechnicalTestService(noOpCatalogPort, noOpApiKeyPort);
PathCheckPort noOpPathCheckPort = new PathCheckPort() {
@Override
public boolean isDirectoryReadable(String path) { return false; }
@Override
public boolean isDirectoryWritableOrCreatable(String path) { return false; }
@Override
public boolean isFileReadable(String path) { return false; }
@Override
public boolean isSqlitePathUsable(String path) { return false; }
};
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
noOpPathCheckPort,
noOpTestService,
() -> java.util.Optional.empty());
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreateDirectory suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.PrepareSqlitePath suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
}
};
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
GuiBatchRunLauncher noOpBatchRunLauncher = (configPath, observer, token) ->
GuiBatchRunLaunchOutcome.rejected(
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
return new GuiStartupContext( return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(), GuiConfigurationEditorStateFactory.createBlankStartState(),
startupNotice, startupNotice,
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(), configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
(values, path) -> GuiConfigurationSaveResult.saved(path)); (values, path) -> GuiConfigurationSaveResult.saved(path),
noOpCatalogPort,
noOpApiKeyPort,
noOpTestService,
noOpPathCheckPort,
noOpOrchestrator,
noOpCorrectionService,
noOpBatchRunLauncher,
rejectingMiniRunLauncher(),
rejectingResetPort(),
rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(),
"dev",
noOpPromptEditorPort(),
noOpHistoryOverviewPort(),
noOpHistoryDetailsPort(),
noOpHistoryResetPort(),
noOpDeleteHistoryPort(),
noOpPromptEditorPortFactory());
}
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 */ };
} }
} }
@@ -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.&nbsp;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;
}
}
@@ -0,0 +1,282 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointId;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestRequest;
import javafx.application.Platform;
/**
* Koordiniert die asynchrone Ausführung der Aktion Technische Tests ausführen"
* für den GUI-Konfigurationseditor.
* <p>
* Dieser Koordinator ist verantwortlich für:
* <ul>
* <li>Lesen des aktuellen GUI-Editorzustands (via {@link Supplier}).</li>
* <li>Aufbau eines {@link TechnicalTestRequest} aus dem aktuellen Zustand.</li>
* <li>Ausführung des {@link TechnicalTestOrchestrator} auf einem dedizierten
* Daemon-Hinterground-Thread namens {@code gui-technical-test}.</li>
* <li>Rückführung des {@link TechnicalTestReport} auf den JavaFX Application Thread
* via {@code Platform.runLater}.</li>
* <li>Einhängen der Ergebnisse als {@link GuiMessageEntry}-Einträge in die geteilte
* {@code pendingMessages}-Liste (Quelle: Technische-Tests").</li>
* <li>Ersetzen vorheriger Test-Einträge (Replace-Semantik) bei jedem neuen Aufruf.</li>
* <li>Weitergabe des vollständigen Berichts an den {@code postResultCallback}, damit
* spätere Arbeitsschritte (z. B. Korrekturhilfen) auf das Ergebnis zugreifen können.</li>
* </ul>
* <p>
* <strong>Threading-Kontrakt:</strong> {@link #triggerTechnicalTests()} darf nur auf dem
* JavaFX Application Thread aufgerufen werden. Hintergrund-Worker-Threads dürfen nur über
* den injizierten {@code resultDelivery}-Verbraucher mit der UI interagieren, der in der
* Produktion {@code Platform.runLater} kapselt.
* <p>
* <strong>Kein implizites Speichern:</strong> Der Koordinator liest den aktuellen GUI-Zustand
* und führt den Test aus, ohne die Konfigurationsdatei zu schreiben oder den Dirty-Zustand
* des Editors zu ändern.
* <p>
* <strong>Anti-Scope:</strong> Dieser Koordinator führt keine schreibenden Korrekturen durch.
* Korrekturvorschläge werden als Bestandteil des {@link TechnicalTestReport} zurückgegeben,
* sind aber nicht ausführbar. Die Ausführung ist einem späteren Arbeitsschritt vorbehalten.
* <p>
* Die Worker-Thread-Factory und die Result-Delivery-Funktion sind injizierbar, damit Tests
* deterministisch ohne echten Hintergrund-Thread laufen können.
*/
public final class GuiTechnicalTestCoordinator {
/** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
static final String SOURCE_TAG = "Technische-Tests";
private static final Logger LOG = LogManager.getLogger(GuiTechnicalTestCoordinator.class);
private final TechnicalTestOrchestrator orchestrator;
private final Supplier<EditorValidationInput> inputProvider;
private final Supplier<String> configFilePathProvider;
private final Supplier<String> logDirectoryProvider;
private final List<GuiMessageEntry> pendingMessages;
private final Consumer<TechnicalTestReport> postResultCallback;
/**
* Factory für den Hintergrund-Worker-Thread. Paket-privat für Test-Substitution.
* Standard: Daemon-Thread namens {@code gui-technical-test}.
*/
Function<Runnable, Thread> testThreadFactory;
/**
* Verbraucher zur Rückführung des Ergebnisses. In der Produktion kapselt er {@code Platform.runLater}.
* In Tests kann er durch einen direkten Aufruf ersetzt werden, damit das Ergebnis sofort
* auf dem Worker-Thread angewendet wird, ohne die FX-Warteschlange zu entwässern.
* Paket-privat für Test-Substitution.
*/
java.util.function.Consumer<Runnable> resultDelivery = Platform::runLater;
/**
* Erstellt einen neuen Koordinator.
*
* @param orchestrator Orchestrator für den vollständigen Gesamttest; darf nicht {@code null} sein
* @param inputProvider Lieferant des aktuellen {@link EditorValidationInput}; darf nicht {@code null} sein
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
* darf nicht {@code null} sein
* @param logDirectoryProvider Lieferant des konfigurierten {@code log.directory}-Rohwerts;
* gibt eine leere Zeichenkette zurück wenn kein Wert konfiguriert ist;
* darf nicht {@code null} sein
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
* @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; darf nicht {@code null} sein
* @throws NullPointerException wenn einer der Parameter {@code null} ist
*/
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
Supplier<EditorValidationInput> inputProvider,
Supplier<String> configFilePathProvider,
Supplier<String> logDirectoryProvider,
List<GuiMessageEntry> pendingMessages,
Consumer<TechnicalTestReport> postResultCallback) {
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider must not be null");
this.configFilePathProvider = Objects.requireNonNull(configFilePathProvider, "configFilePathProvider must not be null");
this.logDirectoryProvider = Objects.requireNonNull(logDirectoryProvider, "logDirectoryProvider must not be null");
this.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages must not be null");
this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
this.testThreadFactory = task -> {
Thread t = new Thread(task, "gui-technical-test");
t.setDaemon(true);
return t;
};
}
/**
* Löst die asynchrone Ausführung des vollständigen technischen Gesamttests aus.
* <p>
* Vor dem Worker-Start wird die geteilte Nachrichtenliste auf dem FX-Thread geleert;
* jeder Aufruf ersetzt die zuvor angefügten Einträge (Replace-Semantik).
* <p>
* Liest den aktuellen Editorzustand und den Konfigurationsdateipfad, baut einen
* {@link TechnicalTestRequest} und startet den {@link TechnicalTestOrchestrator} auf
* einem Hintergrund-Worker-Thread. Das Ergebnis wird via {@code resultDelivery} an den
* JavaFX Application Thread zurückgegeben.
* <p>
* Der Konfigurationsdateipfad wird genutzt, um bei fehlender Prompt-Datei-Konfiguration
* einen sinnvollen Standardpfad ({@code <config-parent>/prompt.txt}) zu bestimmen.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void triggerTechnicalTests() {
// Bestehende Nachrichtenliste auf dem FX-Thread leeren, bevor der Worker-Thread
// startet. Dadurch laufen clear() und nachfolgende add()-Aufrufe (die per
// Platform.runLater wieder auf dem FX-Thread landen) auf demselben Thread und
// es entsteht kein Race-Fenster mit der UI.
pendingMessages.clear();
EditorValidationInput input = inputProvider.get();
String configFilePath = configFilePathProvider.get();
String logDirectory = logDirectoryProvider.get();
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath, logDirectory);
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
Runnable task = () -> {
TechnicalTestReport report = orchestrator.run(request);
resultDelivery.accept(() -> {
applyResult(report);
postResultCallback.accept(report);
});
};
Thread worker = testThreadFactory.apply(task);
worker.start();
}
/**
* Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
* <p>
* Fügt für jedes Checkpoint-Ergebnis einen neuen Eintrag zur geteilten Nachrichtenliste
* hinzu. Die Liste wurde zuvor in {@link #triggerTechnicalTests()} geleert, sodass jeder
* Aufruf einen frischen Stand erzeugt. Zusätzlich wird eine Zusammenfassung angehängt.
* <p>
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
*
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
*/
private void applyResult(TechnicalTestReport report) {
long successCount = 0;
long failureErrorCount = 0;
long failureWarnCount = 0;
long notApplicableCount = 0;
for (CheckpointResult result : report.results()) {
String label = labelFor(result.checkpointId());
switch (result) {
case CheckpointResult.Success success -> {
pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.INFO,
label + ": OK " + success.message(),
SOURCE_TAG));
successCount++;
LOG.info("GUI-Gesamttest: {} → OK", label);
}
case CheckpointResult.Failure failure -> {
GuiMessageSeverity severity = failure.severity() ==
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointSeverity.ERROR
? GuiMessageSeverity.ERROR : GuiMessageSeverity.WARNING;
pendingMessages.add(GuiMessageEntry.of(
severity,
label + ": " + failure.message(),
SOURCE_TAG));
if (severity == GuiMessageSeverity.ERROR) {
failureErrorCount++;
LOG.warn("GUI-Gesamttest: {} → FEHLER: {}", label, failure.message());
} else {
failureWarnCount++;
LOG.warn("GUI-Gesamttest: {} → WARNUNG: {}", label, failure.message());
}
}
case CheckpointResult.NotApplicable notApplicable -> {
pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.HINT,
label + ": nicht anwendbar " + notApplicable.reason(),
SOURCE_TAG));
notApplicableCount++;
LOG.info("GUI-Gesamttest: {} → nicht anwendbar: {}", label, notApplicable.reason());
}
}
}
// Zusammenfassung
long totalFindings = failureErrorCount + failureWarnCount;
String summary = buildSummaryMessage(report.results().size(),
successCount, failureErrorCount, failureWarnCount, notApplicableCount);
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
LOG.info("GUI-Gesamttest abgeschlossen. {} Befunde ({} Erfolg, {} Fehler, {} Warnung, {} nicht anwendbar).",
totalFindings, successCount, failureErrorCount, failureWarnCount, notApplicableCount);
}
/**
* Gibt das deutsche Label für einen Prüfpunkt zurück.
*
* @param id der Prüfpunkt-Bezeichner; darf nicht {@code null} sein
* @return das deutsche Label; nie {@code null}
*/
static String labelFor(CheckpointId id) {
return switch (id) {
case CONFIGURATION_BASIC_VALIDATION -> "Konfiguration grundsätzlich validierbar";
case PROVIDER_CONFIGURATION -> "Provider-Konfiguration prüfbar";
case BASE_URL_REACHABLE -> "Basis-URL/Endpoint erreichbar";
case API_KEY_PRESENT -> "API-Schlüssel vorhanden";
case API_KEY_ACCEPTED -> "API-Schlüssel technisch akzeptiert";
case MODEL_LIST_AVAILABLE -> "Modellliste abrufbar";
case SELECTED_MODEL_PLAUSIBLE -> "Ausgewähltes Modell plausibel";
case PROMPT_FILE_PRESENT -> "Prompt-Datei vorhanden und lesbar";
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
case LOG_DIRECTORY_USABLE -> "Log-Verzeichnis beschreibbar";
};
}
/**
* Baut die deutsche Zusammenfassungsmeldung des Gesamttests.
*
* @param total Gesamtzahl der Prüfpunkte
* @param successCount Anzahl der erfolgreichen Prüfpunkte
* @param errorCount Anzahl der fehlgeschlagenen Prüfpunkte (Schweregrad ERROR)
* @param warningCount Anzahl der Warnungs-Prüfpunkte
* @param notApplicable Anzahl der nicht-anwendbaren Prüfpunkte
* @return deutsche Zusammenfassungsmeldung; nie {@code null}
*/
private static String buildSummaryMessage(long total, long successCount, long errorCount,
long warningCount, long notApplicable) {
long findings = errorCount + warningCount;
String base = "Gesamttest abgeschlossen. " + total + " Prüfpunkte: "
+ successCount + " Erfolg, "
+ errorCount + " Fehler, "
+ warningCount + " Warnung, "
+ notApplicable + " nicht anwendbar.";
if (findings == 0) {
return base + " Keine Befunde.";
}
return base;
}
/**
* Gibt eine unveränderliche Momentaufnahme der aktuell ausstehenden Nachrichten zurück.
* <p>
* Ausschließlich für Tests gedacht.
*
* @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
*/
public List<GuiMessageEntry> pendingMessagesSnapshot() {
return List.copyOf(pendingMessages);
}
}
@@ -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: 10120.";
// -------------------------------------------------------------------------
// 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");
}
}
@@ -1,12 +1,17 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui; package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
/** /**
* JavaFX application entry point for the PDF-Umbenenner GUI inbound adapter. * JavaFX application entry point for the PDF-Umbenenner GUI inbound adapter.
* <p> * <p>
@@ -18,6 +23,9 @@ import org.apache.logging.log4j.Logger;
* {@code titleUpdateListener} hook. The close-request handler is installed through * {@code titleUpdateListener} hook. The close-request handler is installed through
* {@link GuiConfigurationEditorWorkspace#installCloseRequestHandler(Stage)} so that * {@link GuiConfigurationEditorWorkspace#installCloseRequestHandler(Stage)} so that
* unsaved changes are protected when the user tries to close the window. * unsaved changes are protected when the user tries to close the window.
*
* <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert.
* Über das Tray-Kontextmenü kann das Fenster wieder geöffnet oder die Anwendung beendet werden.
*/ */
public class PdfUmbenennerGuiApplication extends Application { 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_WIDTH = 1100;
private static final double DEFAULT_HEIGHT = 800; private static final double DEFAULT_HEIGHT = 800;
private SystemTrayManager trayManager;
/** /**
* Creates a new instance of the JavaFX application. * Creates a new instance of the JavaFX application.
*/ */
@@ -35,9 +45,10 @@ public class PdfUmbenennerGuiApplication extends Application {
/** /**
* Initializes and shows the primary stage. * Initializes and shows the primary stage.
* <p> * <p>
* Lädt die Anwendungs-Icons in allen verfügbaren Größen und setzt sie am Fenster.
* Wires the workspace title-update listener to the stage title so any dirty-state change * Wires the workspace title-update listener to the stage title so any dirty-state change
* causes an immediate window-title refresh. Also installs the close-request handler that * causes an immediate window-title refresh. Installs the close-request handler that
* guards unsaved changes before the window is closed. * guards unsaved changes and minimizes the window to the system tray instead of closing.
* *
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null} * @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
*/ */
@@ -45,31 +56,89 @@ public class PdfUmbenennerGuiApplication extends Application {
public void start(Stage primaryStage) { public void start(Stage primaryStage) {
LOG.info("GUI: JavaFX-Oberfläche wird initialisiert."); LOG.info("GUI: JavaFX-Oberfläche wird initialisiert.");
// 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(); GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext); GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
// Wire the title-update listener so the stage title stays in sync with the dirty state. // Wire the title-update listener so the stage title stays in sync with the dirty state.
workspace.titleUpdateListener = primaryStage::setTitle; workspace.titleUpdateListener = primaryStage::setTitle;
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT); // Statuszeile anlegen und mit dem Workspace verdrahten
GuiStatusBar statusBar = new GuiStatusBar(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.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
primaryStage.setScene(scene); primaryStage.setScene(scene);
// Install the close-request handler that protects unsaved changes. // Install the close-request handler that protects unsaved changes.
workspace.installCloseRequestHandler(primaryStage); workspace.installCloseRequestHandler(primaryStage);
// System-Tray aktivieren: JavaFX-Runtime nicht beenden wenn Fenster versteckt wird
Platform.setImplicitExit(false);
trayManager = new SystemTrayManager(primaryStage);
if (trayManager.install()) {
installTrayCloseHandler(primaryStage, workspace);
}
primaryStage.setMaximized(true);
primaryStage.show(); primaryStage.show();
// Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden.
workspace.autoLoadLastConfiguration();
LOG.info("GUI: Hauptfenster erfolgreich angezeigt."); LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
} }
/** /**
* Called by the JavaFX runtime when the application is stopping. * Called by the JavaFX runtime when the application is stopping.
* <p> * <p>
* Logs the GUI shutdown event. No additional cleanup is required. * Entfernt das System-Tray-Icon und loggt das Beenden.
*/ */
@Override @Override
public void stop() { public void stop() {
LOG.info("GUI: JavaFX-Anwendung wird beendet."); LOG.info("GUI: JavaFX-Anwendung wird beendet.");
if (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();
}
});
} }
} }
@@ -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();
}
}
@@ -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.";
}
}
@@ -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 &gt; 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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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);
}
@@ -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;
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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("");
}
}
}
@@ -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 Farb­wahrnehmung.
* <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");
}
}
@@ -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;
@@ -0,0 +1,63 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.util.StringConverter;
/**
* A JavaFX {@link StringConverter} that maps {@link AiProviderFamily} constants to
* German display labels and back.
* <p>
* Used by the provider selection {@code ComboBox} to show human-readable German names
* while keeping the underlying model type-safe. The reverse conversion
* ({@link #fromString(String)}) supports the same label strings produced by
* {@link #toString(AiProviderFamily)} so that a ComboBox configured as non-editable
* can still convert its selected text back to the enum constant when needed.
* <p>
* Returns {@code null} for inputs that do not match any known constant to signal an
* unrecognised display label.
*/
public final class AiProviderFamilyStringConverter extends StringConverter<AiProviderFamily> {
/**
* Creates a new converter instance.
*/
public AiProviderFamilyStringConverter() {
// Default constructor.
}
/**
* Returns the German display label for the given provider family.
*
* @param family the provider family to convert; may be {@code null}
* @return the German display label, or an empty string when {@code family} is {@code null}
*/
@Override
public String toString(AiProviderFamily family) {
if (family == null) {
return "";
}
return switch (family) {
case CLAUDE -> "Claude";
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
};
}
/**
* Resolves a German display label back to its {@link AiProviderFamily} constant.
*
* @param label the display label as produced by {@link #toString(AiProviderFamily)};
* may be {@code null}
* @return the matching constant, or {@code null} when the label is not recognised
*/
@Override
public AiProviderFamily fromString(String label) {
if (label == null) {
return null;
}
return switch (label) {
case "Claude" -> AiProviderFamily.CLAUDE;
case "OpenAI-kompatibel" -> AiProviderFamily.OPENAI_COMPATIBLE;
default -> null;
};
}
}
@@ -0,0 +1,80 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.List;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
/**
* Inhalt des gesammelten Bestätigungsdialogs für schreibende Korrekturmaßnahmen.
* <p>
* Bevor schreibende Korrekturen aus einem {@link CorrectionPlan} ausgeführt werden,
* zeigt die GUI diesen Inhalt in einem einmaligen Bestätigungsdialog. Der Benutzer
* kann die Korrekturen bestätigen oder ablehnen; ohne Bestätigung werden keine
* Änderungen vorgenommen.
* <p>
* Dieser Record liegt bewusst im GUI-Modul, da er ausschließlich für die
* Darstellung im Bestätigungsdialog der JavaFX-Oberfläche genutzt wird. Er enthält
* selbst keine JavaFX-Typen und kann auf beliebigen Threads erzeugt werden.
* <p>
* Die Beschreibungszeilen ({@link #correctionLines}) entsprechen den
* {@link CorrectionSuggestion#descriptionForUser()}-Texten der im Plan enthaltenen
* Vorschläge in Reihenfolge.
*
* @param title deutscher Dialogtitel; nie {@code null}
* @param introText einleitender deutschsprachiger Text; nie {@code null}
* @param correctionLines Liste der deutschen Beschreibungszeilen, eine pro Korrekturmaßnahme;
* nie {@code null}
*/
public record ConfirmationDialogContent(
String title,
String introText,
List<String> correctionLines) {
/**
* Erstellt einen neuen Bestätigungsdialog-Inhalt.
*
* @param title Dialogtitel; darf nicht {@code null} sein
* @param introText einleitender Text; darf nicht {@code null} sein
* @param correctionLines Beschreibungszeilen; darf nicht {@code null} sein
* @throws NullPointerException wenn ein Parameter {@code null} ist
*/
public ConfirmationDialogContent {
Objects.requireNonNull(title, "title must not be null");
Objects.requireNonNull(introText, "introText must not be null");
Objects.requireNonNull(correctionLines, "correctionLines must not be null");
correctionLines = List.copyOf(correctionLines);
}
/**
* Erstellt den Bestätigungsdialog-Inhalt aus einem {@link CorrectionPlan}.
* <p>
* Die Beschreibungszeilen werden aus den
* {@link CorrectionSuggestion#descriptionForUser()}-Texten der Vorschläge im Plan
* in Reihenfolge übernommen.
*
* @param plan Korrekturplan; darf nicht {@code null} sein
* @return ein neuer Dialoginhalt; nie {@code null}
* @throws NullPointerException wenn {@code plan} {@code null} ist
*/
public static ConfirmationDialogContent fromPlan(CorrectionPlan plan) {
Objects.requireNonNull(plan, "plan must not be null");
List<String> lines = plan.suggestions().stream()
.map(CorrectionSuggestion::descriptionForUser)
.toList();
return new ConfirmationDialogContent(
"Korrekturen bestätigen",
"Folgende technische Korrekturen werden durchgeführt:",
lines);
}
/**
* Gibt an, ob der Dialoginhalt mindestens eine Beschreibungszeile enthält.
*
* @return {@code true} wenn mindestens eine Korrekturmaßnahme beschrieben ist
*/
public boolean hasCorrections() {
return !correctionLines.isEmpty();
}
}
@@ -92,6 +92,7 @@ public final class GuiApiKeyMerger {
current.maxRetriesTransient(), current.maxRetriesTransient(),
current.maxPages(), current.maxPages(),
current.maxTextCharacters(), current.maxTextCharacters(),
current.maxTitleLength(),
current.logAiSensitive(), current.logAiSensitive(),
current.activeProviderFamily(), current.activeProviderFamily(),
merged); merged);
@@ -25,6 +25,7 @@ public final class GuiConfigurationEditorStateFactory {
private static final String PROP_MAX_RETRIES_TRANSIENT = "max.retries.transient"; private static final String PROP_MAX_RETRIES_TRANSIENT = "max.retries.transient";
private static final String PROP_MAX_PAGES = "max.pages"; private static final String PROP_MAX_PAGES = "max.pages";
private static final String PROP_MAX_TEXT_CHARACTERS = "max.text.characters"; private static final String PROP_MAX_TEXT_CHARACTERS = "max.text.characters";
private static final String PROP_MAX_TITLE_LENGTH = "max.title.length";
private static final String PROP_LOG_AI_SENSITIVE = "log.ai.sensitive"; private static final String PROP_LOG_AI_SENSITIVE = "log.ai.sensitive";
private static final String PROP_ACTIVE_PROVIDER = "ai.provider.active"; private static final String PROP_ACTIVE_PROVIDER = "ai.provider.active";
private static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl"; private static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
@@ -74,6 +75,7 @@ public final class GuiConfigurationEditorStateFactory {
propertyOrBlank(properties, PROP_MAX_RETRIES_TRANSIENT), propertyOrBlank(properties, PROP_MAX_RETRIES_TRANSIENT),
propertyOrBlank(properties, PROP_MAX_PAGES), propertyOrBlank(properties, PROP_MAX_PAGES),
propertyOrBlank(properties, PROP_MAX_TEXT_CHARACTERS), propertyOrBlank(properties, PROP_MAX_TEXT_CHARACTERS),
propertyOrBlank(properties, PROP_MAX_TITLE_LENGTH),
propertyOrBlank(properties, PROP_LOG_AI_SENSITIVE), propertyOrBlank(properties, PROP_LOG_AI_SENSITIVE),
propertyOrBlank(properties, PROP_ACTIVE_PROVIDER), propertyOrBlank(properties, PROP_ACTIVE_PROVIDER),
providerConfigurations); providerConfigurations);
@@ -23,7 +23,8 @@ public final class GuiConfigurationTemplateFactory {
private static final String LOG_LEVEL = "INFO"; private static final String LOG_LEVEL = "INFO";
private static final String MAX_RETRIES_TRANSIENT = "3"; private static final String MAX_RETRIES_TRANSIENT = "3";
private static final String MAX_PAGES = "10"; private static final String MAX_PAGES = "10";
private static final String MAX_TEXT_CHARACTERS = "5000"; private static final String MAX_TEXT_CHARACTERS = "1000";
private static final String DEFAULT_MAX_TITLE_LENGTH = "60";
private static final String OPENAI_BASE_URL = "https://api.openai.com/v1"; private static final String OPENAI_BASE_URL = "https://api.openai.com/v1";
private static final String OPENAI_MODEL = "gpt-4o-mini"; private static final String OPENAI_MODEL = "gpt-4o-mini";
@@ -48,15 +49,28 @@ public final class GuiConfigurationTemplateFactory {
} }
/** /**
* Creates the empty editor state used when the GUI starts without a loaded configuration. * Creates the editor state used when the GUI starts without a loaded configuration.
* <p> * <p>
* This start state intentionally does not show the standard template yet. The template * The start state contains the standard configuration template so the GUI shows the
* is reserved for the explicit {@code Neu} action so the GUI starts without an implicit * default values immediately, equivalent to the explicit {@code Neu} action having been
* draft and only shows the welcome guidance until the user requests a new configuration. * triggered. No file snapshot is associated with the state.
* *
* @return a clean editor state without a loaded file snapshot and without template values * @return a clean editor state with the standard template values and no loaded file snapshot
*/ */
public static GuiConfigurationEditorState createBlankStartState() { public static GuiConfigurationEditorState createBlankStartState() {
return createStandardTemplate();
}
/**
* Creates a truly empty editor state without any template values.
* <p>
* This factory is reserved for tests that intentionally need an editor state with empty
* field values and no provider configurations. Production startup uses
* {@link #createBlankStartState()} which returns the standard template instead.
*
* @return a clean editor state without any template values
*/
public static GuiConfigurationEditorState createEmptyStartState() {
GuiConfigurationValues blankValues = new GuiConfigurationValues( GuiConfigurationValues blankValues = new GuiConfigurationValues(
"", "",
"", "",
@@ -70,6 +84,7 @@ public final class GuiConfigurationTemplateFactory {
"", "",
"", "",
"", "",
"",
Map.of()); Map.of());
return new GuiConfigurationEditorState(Optional.empty(), blankValues, blankValues, Optional.empty()); return new GuiConfigurationEditorState(Optional.empty(), blankValues, blankValues, Optional.empty());
} }
@@ -103,6 +118,7 @@ public final class GuiConfigurationTemplateFactory {
MAX_RETRIES_TRANSIENT, MAX_RETRIES_TRANSIENT,
MAX_PAGES, MAX_PAGES,
MAX_TEXT_CHARACTERS, MAX_TEXT_CHARACTERS,
DEFAULT_MAX_TITLE_LENGTH,
Boolean.toString(false), Boolean.toString(false),
AiProviderFamily.CLAUDE.getIdentifier(), AiProviderFamily.CLAUDE.getIdentifier(),
providerConfigurations); providerConfigurations);
@@ -23,6 +23,7 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
* @param maxRetriesTransient transient retry limit as editable text * @param maxRetriesTransient transient retry limit as editable text
* @param maxPages page limit as editable text * @param maxPages page limit as editable text
* @param maxTextCharacters text limit as editable text * @param maxTextCharacters text limit as editable text
* @param maxTitleLength maximum base-title length as editable text
* @param logAiSensitive raw value of {@code log.ai.sensitive} as editable text * @param logAiSensitive raw value of {@code log.ai.sensitive} as editable text
* @param activeProviderFamily raw value of {@code ai.provider.active} as editable text * @param activeProviderFamily raw value of {@code ai.provider.active} as editable text
* @param providerConfigurations provider-specific editor state keyed by provider family * @param providerConfigurations provider-specific editor state keyed by provider family
@@ -38,6 +39,7 @@ public record GuiConfigurationValues(
String maxRetriesTransient, String maxRetriesTransient,
String maxPages, String maxPages,
String maxTextCharacters, String maxTextCharacters,
String maxTitleLength,
String logAiSensitive, String logAiSensitive,
String activeProviderFamily, String activeProviderFamily,
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) { Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
@@ -55,6 +57,7 @@ public record GuiConfigurationValues(
* @param maxRetriesTransient transient retry limit; {@code null} becomes an empty string * @param maxRetriesTransient transient retry limit; {@code null} becomes an empty string
* @param maxPages page limit; {@code null} becomes an empty string * @param maxPages page limit; {@code null} becomes an empty string
* @param maxTextCharacters text limit; {@code null} becomes an empty string * @param maxTextCharacters text limit; {@code null} becomes an empty string
* @param maxTitleLength maximum base-title length; {@code null} becomes an empty string
* @param logAiSensitive raw {@code log.ai.sensitive} value; {@code null} becomes an empty string * @param logAiSensitive raw {@code log.ai.sensitive} value; {@code null} becomes an empty string
* @param activeProviderFamily raw {@code ai.provider.active} value; {@code null} becomes an empty string * @param activeProviderFamily raw {@code ai.provider.active} value; {@code null} becomes an empty string
* @param providerConfigurations provider-specific state map; must not be {@code null} * @param providerConfigurations provider-specific state map; must not be {@code null}
@@ -70,6 +73,7 @@ public record GuiConfigurationValues(
maxRetriesTransient = normalizeText(maxRetriesTransient); maxRetriesTransient = normalizeText(maxRetriesTransient);
maxPages = normalizeText(maxPages); maxPages = normalizeText(maxPages);
maxTextCharacters = normalizeText(maxTextCharacters); maxTextCharacters = normalizeText(maxTextCharacters);
maxTitleLength = normalizeText(maxTitleLength);
logAiSensitive = normalizeText(logAiSensitive); logAiSensitive = normalizeText(logAiSensitive);
activeProviderFamily = normalizeText(activeProviderFamily); activeProviderFamily = normalizeText(activeProviderFamily);
@@ -98,7 +102,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withActiveProviderFamily(String providerFamily) { public GuiConfigurationValues withActiveProviderFamily(String providerFamily) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, providerFamily, providerConfigurations); maxTitleLength, logAiSensitive, providerFamily, providerConfigurations);
} }
/** /**
@@ -110,7 +114,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withSourceFolder(String value) { public GuiConfigurationValues withSourceFolder(String value) {
return new GuiConfigurationValues(value, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(value, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -122,7 +126,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withTargetFolder(String value) { public GuiConfigurationValues withTargetFolder(String value) {
return new GuiConfigurationValues(sourceFolder, value, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, value, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -134,7 +138,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withSqliteFile(String value) { public GuiConfigurationValues withSqliteFile(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, value, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, value, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -146,7 +150,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withPromptTemplateFile(String value) { public GuiConfigurationValues withPromptTemplateFile(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, value, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, value,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -158,7 +162,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withRuntimeLockFile(String value) { public GuiConfigurationValues withRuntimeLockFile(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
value, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, value, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -170,7 +174,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withLogDirectory(String value) { public GuiConfigurationValues withLogDirectory(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, value, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, runtimeLockFile, value, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -182,7 +186,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withLogLevel(String value) { public GuiConfigurationValues withLogLevel(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, value, maxRetriesTransient, maxPages, maxTextCharacters, runtimeLockFile, logDirectory, value, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -194,7 +198,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withMaxRetriesTransient(String value) { public GuiConfigurationValues withMaxRetriesTransient(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, value, maxPages, maxTextCharacters, runtimeLockFile, logDirectory, logLevel, value, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -206,7 +210,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withMaxPages(String value) { public GuiConfigurationValues withMaxPages(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, value, maxTextCharacters, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, value, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -218,7 +222,19 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withMaxTextCharacters(String value) { public GuiConfigurationValues withMaxTextCharacters(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, value, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, value,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different maximum base-title length value.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested title-length value
*/
public GuiConfigurationValues withMaxTitleLength(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
value, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -230,7 +246,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withLogAiSensitive(String value) { public GuiConfigurationValues withLogAiSensitive(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
value, activeProviderFamily, providerConfigurations); maxTitleLength, value, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -243,7 +259,7 @@ public record GuiConfigurationValues(
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) { Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations); maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
} }
/** /**
@@ -0,0 +1,93 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/**
* Represents the result of validating the current editor state in the GUI configuration editor.
* <p>
* Each validation run produces one immutable result containing all findings split into two
* complementary views:
* <ul>
* <li>{@code messages} a consolidated list of {@link GuiMessageEntry} objects that feed
* the central message area.</li>
* <li>{@code fieldFindings} field-specific {@link GuiFieldFinding} objects that are
* rendered directly below the affected input fields.</li>
* </ul>
* <p>
* A single root cause may appear in both lists: once as a central message (with full context)
* and once as a compact field finding (with a short, field-specific description).
* <p>
* The {@code evaluatedAt} timestamp records when the validation ran; the GUI may use it to
* determine whether a displayed result is still current.
* <p>
* This record contains no JavaFX references and can be created and inspected on any thread.
*
* @param messages consolidated list of message entries for the central message area;
* never {@code null}
* @param fieldFindings list of field-level findings; never {@code null}
* @param evaluatedAt instant at which the validation was performed; never {@code null}
*/
public record GuiEditorValidationResult(
List<GuiMessageEntry> messages,
List<GuiFieldFinding> fieldFindings,
Instant evaluatedAt) {
/**
* Creates a new validation result.
*
* @param messages central message entries; must not be {@code null}
* @param fieldFindings field-level findings; must not be {@code null}
* @param evaluatedAt validation timestamp; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
*/
public GuiEditorValidationResult {
Objects.requireNonNull(messages, "messages must not be null");
Objects.requireNonNull(fieldFindings, "fieldFindings must not be null");
Objects.requireNonNull(evaluatedAt, "evaluatedAt must not be null");
messages = List.copyOf(messages);
fieldFindings = List.copyOf(fieldFindings);
}
/**
* Returns an empty validation result representing the state before any validation has run.
* <p>
* Callers must not interpret an empty result as "no errors found"; they should wait for
* a non-empty result from the first actual validation run.
*
* @return an empty result with the current instant as timestamp; never {@code null}
*/
public static GuiEditorValidationResult empty() {
return new GuiEditorValidationResult(List.of(), List.of(), Instant.now());
}
/**
* Returns {@code true} when at least one message or field finding has severity
* {@link GuiMessageSeverity#ERROR}.
* <p>
* A result with errors indicates that the current editor state is not operational and
* should not be relied upon to start a processing run without correction.
*
* @return {@code true} when at least one error is present
*/
public boolean hasErrors() {
boolean messageError = messages.stream()
.anyMatch(m -> m.severity() == GuiMessageSeverity.ERROR);
boolean fieldError = fieldFindings.stream()
.anyMatch(f -> f.severity() == GuiMessageSeverity.ERROR);
return messageError || fieldError;
}
/**
* Returns {@code true} when the findings list contains at least one finding for the
* requested field key, regardless of severity.
*
* @param fieldKey the property key to look up; must not be {@code null}
* @return {@code true} when at least one finding refers to the requested field
*/
public boolean hasFieldFindingFor(String fieldKey) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
return fieldFindings.stream().anyMatch(f -> f.fieldKey().equals(fieldKey));
}
}
@@ -0,0 +1,68 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.Objects;
/**
* Represents a field-level validation finding that is displayed directly below the affected
* input field in the GUI configuration editor.
* <p>
* Field-level findings complement the central message area: the central area shows all findings
* in a consolidated list while this type carries the finding directly to the specific field it
* relates to, making it easier for the user to identify and correct the problem.
* <p>
* The {@code fieldKey} uses the property key as defined in the {@code .properties} file
* (e.g., {@code "source.folder"}, {@code "ai.provider.openai-compatible.apiKey"}).
* Using the property key as the field identifier keeps the validation model stable and independent
* of GUI layout changes.
* <p>
* Field-level findings are always rendered as small red German-language text directly beneath
* the affected control. Findings with severity {@link GuiMessageSeverity#INFO} or
* {@link GuiMessageSeverity#HINT} may also be shown field-near when the context is helpful.
* <p>
* This record contains no JavaFX references and is safe to create on any thread.
*
* @param fieldKey the property key identifying the affected configuration field; never {@code null}
* @param severity the severity of this finding; never {@code null}
* @param text short, German-language description of the problem; never {@code null}
*/
public record GuiFieldFinding(
String fieldKey,
GuiMessageSeverity severity,
String text) {
/**
* Creates a new field-level finding.
*
* @param fieldKey property key of the affected field; must not be {@code null}
* @param severity severity of the finding; must not be {@code null}
* @param text German-language problem description; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
*/
public GuiFieldFinding {
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(text, "text must not be null");
}
/**
* Creates an error-severity field finding.
*
* @param fieldKey property key of the affected field; must not be {@code null}
* @param text German-language problem description; must not be {@code null}
* @return a new finding with severity {@link GuiMessageSeverity#ERROR}
*/
public static GuiFieldFinding error(String fieldKey, String text) {
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.ERROR, text);
}
/**
* Creates a warning-severity field finding.
*
* @param fieldKey property key of the affected field; must not be {@code null}
* @param text German-language problem description; must not be {@code null}
* @return a new finding with severity {@link GuiMessageSeverity#WARNING}
*/
public static GuiFieldFinding warning(String fieldKey, String text) {
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.WARNING, text);
}
}
@@ -0,0 +1,45 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.Objects;
/**
* Represents the model identifier entered manually by the user when no remote model list
* is available for the active provider.
* <p>
* This record captures both the provider context and the user-supplied model name so that
* later GUI layers can decide whether the value is still applicable after a provider change
* or a successful remote list retrieval.
* <p>
* A manually entered model name is discarded when a remote model list is subsequently loaded
* and the previously entered value does not appear in that list.
*
* @param providerIdentifier identifier of the provider for which the model was entered;
* never {@code null}
* @param modelName model identifier as typed by the user; never {@code null},
* but may be blank when the user has not yet entered anything
*/
public record GuiManualModelEntry(
String providerIdentifier,
String modelName) {
/**
* Creates a new manual model entry.
*
* @param providerIdentifier identifier of the provider; must not be {@code null}
* @param modelName model name as entered by the user; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
*/
public GuiManualModelEntry {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
Objects.requireNonNull(modelName, "modelName must not be null");
}
/**
* Returns whether the model name is non-blank, i.e. whether the user has entered something.
*
* @return {@code true} when the model name contains at least one non-whitespace character
*/
public boolean hasModelName() {
return !modelName.isBlank();
}
}
@@ -0,0 +1,68 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* Represents a single entry in the central message area of the GUI configuration editor.
* <p>
* Each entry carries a severity level, the message text, an optional source label that
* identifies the subsystem that produced the message (e.g., "Modellabruf", "Validierung"),
* and a timestamp. The GUI renders the {@link GuiMessageSeverity#getPrefixLabel() prefix} of
* the severity in colour while the message text itself remains black.
* <p>
* Instances are immutable and contain no JavaFX references; they are safe to create on
* background threads and pass to the JavaFX Application Thread via {@code Platform.runLater}.
*
* @param severity the severity of this message; never {@code null}
* @param text the message text; never {@code null}
* @param source optional label identifying the origin subsystem; empty when not applicable
* @param timestamp the instant at which the message was created; never {@code null}
*/
public record GuiMessageEntry(
GuiMessageSeverity severity,
String text,
Optional<String> source,
Instant timestamp) {
/**
* Creates a new message entry.
*
* @param severity severity level; must not be {@code null}
* @param text message text; must not be {@code null}
* @param source optional source label; {@code null} is treated as {@link Optional#empty()}
* @param timestamp creation timestamp; must not be {@code null}
* @throws NullPointerException if {@code severity}, {@code text}, or {@code timestamp} is {@code null}
*/
public GuiMessageEntry {
Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(text, "text must not be null");
Objects.requireNonNull(timestamp, "timestamp must not be null");
source = source == null ? Optional.empty() : source;
}
/**
* Creates a message entry without a source label, using the current instant as timestamp.
*
* @param severity severity level; must not be {@code null}
* @param text message text; must not be {@code null}
* @return a new entry; never {@code null}
*/
public static GuiMessageEntry of(GuiMessageSeverity severity, String text) {
return new GuiMessageEntry(severity, text, Optional.empty(), Instant.now());
}
/**
* Creates a message entry with a source label, using the current instant as timestamp.
*
* @param severity severity level; must not be {@code null}
* @param text message text; must not be {@code null}
* @param source source subsystem label; must not be {@code null}
* @return a new entry; never {@code null}
*/
public static GuiMessageEntry of(GuiMessageSeverity severity, String text, String source) {
Objects.requireNonNull(source, "source must not be null");
return new GuiMessageEntry(severity, text, Optional.of(source), Instant.now());
}
}
@@ -0,0 +1,71 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
/**
* Defines the four fixed severity levels for messages displayed in the central message area
* of the GUI configuration editor.
* <p>
* Each level carries a German-language prefix string that is displayed in colour at the start
* of each message line. The remainder of the message text is always rendered in black,
* regardless of severity.
* <p>
* The colour hints in this enum are expressed as CSS colour strings to avoid a compile-time
* dependency on JavaFX. Rendering code in the GUI layer must convert the hint to a
* {@code javafx.scene.paint.Color} or equivalent.
* <p>
* Severity levels ordered from least to most critical:
* <ol>
* <li>{@link #INFO}</li>
* <li>{@link #HINT}</li>
* <li>{@link #WARNING}</li>
* <li>{@link #ERROR}</li>
* </ol>
* <p>
* This enum contains no JavaFX references and is safe to use in unit-tested view-model code.
*/
public enum GuiMessageSeverity {
/** Neutral informational message, no action required. */
INFO("Info:", "#1565c0"),
/** Helpful hint that the user may want to act on. */
HINT("Hinweis:", "#558b2f"),
/** Configuration value is technically acceptable but risky or unusual. */
WARNING("Warnung:", "#e65100"),
/** Configuration value is invalid or the state is not operational. */
ERROR("Fehler:", "#b71c1c");
private final String prefixLabel;
private final String prefixCssColour;
GuiMessageSeverity(String prefixLabel, String prefixCssColour) {
this.prefixLabel = prefixLabel;
this.prefixCssColour = prefixCssColour;
}
/**
* Returns the German-language prefix label shown at the start of each message line of this severity.
* <p>
* Only the prefix is rendered in colour; the remaining message text is always black.
*
* @return the prefix label; never {@code null}
*/
public String getPrefixLabel() {
return prefixLabel;
}
/**
* Returns a CSS colour string hint that the GUI layer uses to render the prefix in the
* appropriate colour.
* <p>
* The returned value is a CSS hex colour (e.g., {@code "#b71c1c"}) that can be passed to
* {@code Color.web()} in JavaFX. The GUI layer is responsible for this conversion; this
* enum itself contains no JavaFX dependency.
*
* @return a CSS colour hint string; never {@code null}
*/
public String getPrefixCssColour() {
return prefixCssColour;
}
}
@@ -0,0 +1,214 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
/**
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
* for model identifier input, depending on the current {@link GuiModelSource}.
* <p>
* When the model source is {@link GuiModelSource#LIST_REMOTE_SUCCESS}, a non-editable
* {@code ComboBox} is shown, pre-populated with the remote list and with the first model
* pre-selected. In all other cases (including {@link GuiModelSource#NOT_YET_LOADED}) the
* manual text field is shown, which may be empty or disabled depending on the source state.
* <p>
* Exactly one child is {@code visible} and {@code managed} at any time. The other child is
* kept in the scene graph with both flags set to {@code false} so that no blank space appears.
* <p>
* This class contains JavaFX references and must only be used on the JavaFX Application Thread.
*/
public final class GuiModelFieldContainer extends StackPane {
private final ComboBox<String> comboBox;
private final TextField textField;
private final Consumer<String> onModelChange;
private GuiModelSource currentSource;
/**
* Guard flag that suppresses the change callback while the text field value is being set
* programmatically via {@link #setTextFieldValue(String)}. The callback must still fire on
* genuine user edits, so the guard is scoped tightly around the programmatic write only.
*/
private boolean programmaticTextFieldSet = false;
/**
* Creates a new model field container.
*
* @param initialModelValue the initial model text shown in the text field; may be blank
* @param onModelChange callback invoked on every model-value change; must not be {@code null}
*/
public GuiModelFieldContainer(String initialModelValue, Consumer<String> onModelChange) {
this.onModelChange = Objects.requireNonNull(onModelChange, "onModelChange must not be null");
this.currentSource = GuiModelSource.NOT_YET_LOADED;
this.textField = new TextField(initialModelValue == null ? "" : initialModelValue);
this.textField.textProperty().addListener((obs, oldText, newText) -> {
if (!programmaticTextFieldSet && !newText.equals(oldText)) {
onModelChange.accept(newText);
}
});
this.comboBox = new ComboBox<>();
this.comboBox.setEditable(false);
this.comboBox.valueProperty().addListener((obs, oldVal, newVal) -> {
if (newVal != null && !newVal.equals(oldVal)) {
onModelChange.accept(newVal);
}
});
// Initial state: show text field (NOT_YET_LOADED manual input)
applyVisibility(false);
setAlignment(Pos.CENTER_LEFT);
getChildren().addAll(comboBox, textField);
}
/**
* Returns the currently displayed model value.
* <p>
* When the {@code ComboBox} is active, returns the selected item. When the text field
* is active, returns the text field content. Never returns {@code null}.
*
* @return the current model value; never {@code null}
*/
public String currentModelValue() {
if (currentSource == GuiModelSource.LIST_REMOTE_SUCCESS) {
String val = comboBox.getValue();
return val == null ? "" : val;
}
return textField.getText() == null ? "" : textField.getText();
}
/**
* Returns the current model source state.
*
* @return the current {@link GuiModelSource}; never {@code null}
*/
public GuiModelSource currentSource() {
return currentSource;
}
/**
* Applies a successful model list and switches to the non-editable {@link ComboBox}.
* <p>
* If the previously active manual text value is present in the new list it is kept as the
* selection; otherwise the first model in the list is pre-selected and the former manual
* value is discarded.
* <p>
* Must be called on the JavaFX Application Thread.
*
* @param models non-empty list of model identifiers; must not be {@code null} or empty
* @param previousManualValue the model text that was in the text field before this call;
* used to decide whether to preserve the selection
* @throws IllegalArgumentException if {@code models} is empty
*/
public void applyModelList(List<String> models, String previousManualValue) {
Objects.requireNonNull(models, "models must not be null");
if (models.isEmpty()) {
throw new IllegalArgumentException("models must not be empty");
}
comboBox.getItems().setAll(models);
// Preserve the previous value only when it appears in the new list.
String previous = previousManualValue == null ? "" : previousManualValue;
if (!previous.isBlank() && models.contains(previous)) {
comboBox.setValue(previous);
} else {
comboBox.setValue(models.get(0));
}
currentSource = GuiModelSource.LIST_REMOTE_SUCCESS;
applyVisibility(true);
// Notify the callback about the newly selected value.
String selected = comboBox.getValue();
if (selected != null) {
onModelChange.accept(selected);
}
}
/**
* Switches to the manual text field with the given fallback source state.
* <p>
* The text field retains whatever value it currently holds (or the value set programmatically
* via {@link #setTextFieldValue(String)}). Must be called on the JavaFX Application Thread.
*
* @param source the non-success source state; must not be {@link GuiModelSource#LIST_REMOTE_SUCCESS}
*/
public void applyManualFallback(GuiModelSource source) {
Objects.requireNonNull(source, "source must not be null");
if (source == GuiModelSource.LIST_REMOTE_SUCCESS) {
throw new IllegalArgumentException(
"applyManualFallback must not be called with LIST_REMOTE_SUCCESS");
}
currentSource = source;
applyVisibility(false);
}
/**
* Programmatically sets the text field value without triggering the change callback.
* <p>
* Useful for restoring a saved model value after a provider switch. Must be called on the
* JavaFX Application Thread.
*
* @param value the new text field value; {@code null} is treated as an empty string
*/
public void setTextFieldValue(String value) {
programmaticTextFieldSet = true;
try {
textField.setText(value == null ? "" : value);
} finally {
programmaticTextFieldSet = false;
}
}
/**
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf beide internen Controls
* (ComboBox und TextField). Damit erscheint der Tooltip unabhängig davon, welches der
* beiden Controls gerade sichtbar ist.
* <p>
* Darf nur auf dem JavaFX Application Thread aufgerufen werden.
*
* @param tooltipText der anzuzeigende Tooltip-Text; darf nicht leer sein
*/
public void applyTooltip(String tooltipText) {
Objects.requireNonNull(tooltipText, "tooltipText darf nicht null sein");
Tooltip comboTooltip = new Tooltip(tooltipText);
comboTooltip.setShowDelay(Duration.millis(300));
comboBox.setTooltip(comboTooltip);
Tooltip textTooltip = new Tooltip(tooltipText);
textTooltip.setShowDelay(Duration.millis(300));
textField.setTooltip(textTooltip);
}
/**
* Returns the JavaFX node that represents this container and can be added to the scene graph.
*
* @return {@code this} container; never {@code null}
*/
public Node asNode() {
return this;
}
/**
* Applies visibility to the ComboBox and TextField based on whether the list is active.
*
* @param listActive {@code true} to show the ComboBox, {@code false} to show the TextField
*/
private void applyVisibility(boolean listActive) {
comboBox.setVisible(listActive);
comboBox.setManaged(listActive);
textField.setVisible(!listActive);
textField.setManaged(!listActive);
}
}
@@ -0,0 +1,54 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
/**
* Describes the origin of the currently displayed model value in the GUI model selection area.
* <p>
* The GUI uses this enum to decide which control to show for the model input:
* <ul>
* <li>When the source is {@link #LIST_REMOTE_SUCCESS}, a non-editable {@code ComboBox}
* is shown, pre-populated with the remote list.</li>
* <li>When the source is {@link #LIST_UNAVAILABLE_MANUAL_INPUT} or
* {@link #LIST_FAILED_MANUAL_INPUT}, a plain text input field is shown instead,
* allowing the user to enter the model name manually.</li>
* <li>{@link #NOT_YET_LOADED} represents the initial state before the first retrieval
* attempt; the GUI should render a loading indicator or show the text field
* in a disabled/pending state.</li>
* </ul>
* <p>
* This enum is intentionally free of JavaFX references so it can be used in unit-tested
* view-model code without starting a JavaFX runtime.
*/
public enum GuiModelSource {
/**
* The model list was successfully retrieved from the remote provider endpoint.
* <p>
* A non-editable {@code ComboBox} is displayed, pre-selecting the first available model.
*/
LIST_REMOTE_SUCCESS,
/**
* No model list is available because the provider does not expose a model catalogue endpoint
* or because the configuration was incomplete.
* <p>
* A manual text input field is shown and the user must enter the model identifier by hand.
*/
LIST_UNAVAILABLE_MANUAL_INPUT,
/**
* A technical error occurred while retrieving the model list (e.g., HTTP error, timeout,
* authentication failure).
* <p>
* A manual text input field is shown so the user can still supply a model name; the GUI
* also reports the failure in the central message area.
*/
LIST_FAILED_MANUAL_INPUT,
/**
* The initial state before the first model retrieval attempt has been made.
* <p>
* The GUI should indicate that a retrieval is pending and must not present the manual
* input field as the definitive fallback until at least one retrieval attempt has completed.
*/
NOT_YET_LOADED
}
@@ -0,0 +1,98 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
/**
* Represents which provider section is currently visible in the GUI and preserves
* the editable configuration state of the section that is currently hidden.
* <p>
* The GUI shows exactly one provider section at a time. When the user switches the
* active provider, the previously visible section must not lose its field values.
* This record captures the current display context as an immutable snapshot so that
* view-model code can reason about visibility and data preservation without touching
* JavaFX nodes directly.
* <p>
* Instances of this record contain no JavaFX references and are safe to create and
* inspect from any thread, including unit-test threads.
*
* @param visibleProvider the provider family whose configuration section is
* currently rendered; never {@code null}
* @param visibleProviderState the editable configuration state currently displayed;
* never {@code null}
* @param hiddenProviderState the editable configuration state of the provider that
* is not shown, preserved here so it is not lost on switch;
* never {@code null}
* @param hiddenProvider the provider family whose section is currently hidden;
* never {@code null}
*/
public record GuiVisibleProviderSection(
AiProviderFamily visibleProvider,
GuiProviderConfigurationState visibleProviderState,
AiProviderFamily hiddenProvider,
GuiProviderConfigurationState hiddenProviderState) {
/**
* Creates a new visible-provider section snapshot.
*
* @param visibleProvider provider whose section is shown; must not be {@code null}
* @param visibleProviderState configuration state of the visible provider; must not be {@code null}
* @param hiddenProvider provider whose section is hidden; must not be {@code null}
* @param hiddenProviderState configuration state of the hidden provider; must not be {@code null}
* @throws NullPointerException if any parameter is {@code null}
* @throws IllegalArgumentException if {@code visibleProvider} and {@code hiddenProvider} are equal
*/
public GuiVisibleProviderSection {
Objects.requireNonNull(visibleProvider, "visibleProvider must not be null");
Objects.requireNonNull(visibleProviderState, "visibleProviderState must not be null");
Objects.requireNonNull(hiddenProvider, "hiddenProvider must not be null");
Objects.requireNonNull(hiddenProviderState, "hiddenProviderState must not be null");
if (visibleProvider == hiddenProvider) {
throw new IllegalArgumentException(
"visibleProvider and hiddenProvider must be different, but both are: " + visibleProvider);
}
}
/**
* Returns the configuration state for the requested provider family.
*
* @param family the provider family to retrieve the state for; must not be {@code null}
* @return the state for the requested provider
* @throws IllegalArgumentException if the requested family is neither the visible nor the hidden provider
*/
public GuiProviderConfigurationState stateFor(AiProviderFamily family) {
Objects.requireNonNull(family, "family must not be null");
if (family == visibleProvider) {
return visibleProviderState;
}
if (family == hiddenProvider) {
return hiddenProviderState;
}
throw new IllegalArgumentException("Unknown provider family: " + family);
}
/**
* Returns a copy with the visible and hidden providers swapped, preserving both states.
* <p>
* The previously hidden provider becomes visible and the previously visible provider
* moves to hidden. No field values are lost during the switch.
*
* @return a new section snapshot with providers and their states swapped
*/
public GuiVisibleProviderSection switchProvider() {
return new GuiVisibleProviderSection(hiddenProvider, hiddenProviderState,
visibleProvider, visibleProviderState);
}
/**
* Returns a copy with a different configuration state for the visible provider.
*
* @param newState the updated configuration state; must not be {@code null}
* @return a new section snapshot with the visible provider's state replaced
*/
public GuiVisibleProviderSection withVisibleProviderState(GuiProviderConfigurationState newState) {
Objects.requireNonNull(newState, "newState must not be null");
return new GuiVisibleProviderSection(visibleProvider, newState, hiddenProvider, hiddenProviderState);
}
}
@@ -1,12 +1,43 @@
/** /**
* Editor state and template model for the JavaFX configuration editor. * Editor state and view-model types for the JavaFX configuration editor.
* <p> * <p>
* This package contains the GUI-side representation of configuration data that can be edited * This package contains the GUI-side representation of configuration data that can be edited
* independently from file I/O and validation. It separates loaded file snapshots, baseline * independently from file I/O and validation. It covers:
* editor values, current editor values, provider-specific API key state, and the derived * <ul>
* dirty-state view used by the GUI. * <li>Loaded file snapshots, baseline editor values, current editor values and the derived
* dirty-state view ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues}).</li>
* <li>Provider-specific configuration state and API-key state
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState}).</li>
* <li>Provider section visibility and state preservation across provider switches
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection}).</li>
* <li>Model source classification, manual model entry, and the JavaFX model field container
* that switches between a non-editable {@code ComboBox} and a text field depending on
* retrieval outcome
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiManualModelEntry},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}).</li>
* <li>Message severity, central message entries and field-level validation findings
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiFieldFinding}).</li>
* <li>The consolidated validation result that feeds both the central message area and
* field-near error display
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult}).</li>
* <li>The confirmation dialog content for collected write-corrective actions
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent}).</li>
* </ul>
* <p> * <p>
* The classes in this package are intentionally free of JavaFX controls so they can be reused * Most classes in this package are intentionally free of JavaFX controls so they can be used
* by later GUI layers without coupling the model to a particular layout implementation. * in unit-tested view-model code without starting a JavaFX runtime. The exception is
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}, which
* extends a JavaFX {@code StackPane} and must be used only on the JavaFX Application Thread.
* <p>
* Types that are not GUI-specific (API-key origin provenance, model catalogue results and
* the corresponding port contract) live in
* {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog} to keep the
* Application module free of GUI dependencies while allowing future non-GUI consumers
* to reuse these types without depending on this adapter module.
*/ */
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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();
}
}
@@ -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

@@ -1,32 +1,17 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Optional; import java.util.Optional;
import java.util.function.BooleanSupplier;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
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 org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll; 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.Test;
import org.junit.jupiter.api.TestMethodOrder; 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. * Monocle-based headless smoke tests for the GUI adapter module.
* <p> * <p>
@@ -95,10 +86,17 @@ class GuiAdapterSmokeTest {
static void setUpJavaFxPlatform() throws InterruptedException { static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false); Platform.setImplicitExit(false);
CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch startLatch = new CountDownLatch(1);
try {
Platform.startup(() -> { Platform.startup(() -> {
PLATFORM_STARTED.set(true); PLATFORM_STARTED.set(true);
startLatch.countDown(); 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( assertTrue(
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless"); "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 * Verifies that the editor workspace starts without a loaded configuration, immediately
* welcome guidance, and exposes the fixed GUI structure of the current shell. * 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 * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
@Order(5) @Order(5)
void editorWorkspace_startStateShowsEmptyHeaderWelcomeGuidanceAndOneTab() throws Exception { void editorWorkspace_startStateShowsEmptyHeaderDefaultsAndOneTab() throws Exception {
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>(); AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<GuiConfigurationEditorWorkspace> workspaceReference = new AtomicReference<>(); AtomicReference<GuiConfigurationEditorWorkspace> workspaceReference = new AtomicReference<>();
@@ -234,10 +232,8 @@ class GuiAdapterSmokeTest {
assertEquals("", workspace.configurationPathText(), assertEquals("", workspace.configurationPathText(),
"The header path must stay empty before any configuration is loaded"); "The header path must stay empty before any configuration is loaded");
assertTrue(workspace.isWelcomeGuidanceVisible(), assertFalse(workspace.isWelcomeGuidanceVisible(),
"The welcome guidance must be visible in the unloaded start state"); "The welcome guidance must stay hidden because the standard template is shown immediately");
assertTrue(workspace.welcomeText().contains("Willkommen"),
"The welcome text must be shown in German");
assertNotNull(workspace.root(), assertNotNull(workspace.root(),
"The workspace root must be available"); "The workspace root must be available");
assertEquals("Neu", workspace.newButton().getText(), assertEquals("Neu", workspace.newButton().getText(),
@@ -248,14 +244,20 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible"); "The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(), assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible"); "The 'Speichern unter' button must be visible");
assertEquals(1, workspace.tabPane().getTabs().size(), assertEquals(4, workspace.tabPane().getTabs().size(),
"Exactly one configuration tab must be present"); "Configuration tab, processing-run tab, history tab and prompt editor tab must all be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(), assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The 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( assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen", "Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()), 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) { } catch (Throwable t) {
fxError.set(t); fxError.set(t);
} finally { } finally {
@@ -395,7 +397,36 @@ class GuiAdapterSmokeTest {
GuiConfigurationTemplateFactory.createStandardTemplate(), GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(), Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
testWriter); testWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context); GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
workspaceRef.set(workspace); workspaceRef.set(workspace);
@@ -1,9 +1,9 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui; package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
/** /**
* Unit tests for {@link GuiAdapter}. * Unit tests for {@link GuiAdapter}.
* <p> * <p>
@@ -156,8 +156,8 @@ class GuiConfigurationEditorWorkspaceSaveTest {
GuiProviderApiKeyState.unresolved(openaiApiKey))); GuiProviderApiKeyState.unresolved(openaiApiKey)));
return new GuiConfigurationValues( return new GuiConfigurationValues(
"./source", "./target", "./db.sqlite", "./prompt.txt", "./source", "./target", "./db.sqlite", "./prompt.txt",
"./app.lock", "./logs", "INFO", "3", "10", "5000", "./app.lock", "./logs", "INFO", "3", "10", "1000",
"false", "claude", providers); "60", "false", "claude", providers);
} }
private GuiConfigurationEditorState buildState(GuiConfigurationValues baseline, private GuiConfigurationEditorState buildState(GuiConfigurationValues baseline,
@@ -0,0 +1,316 @@
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.assertTrue;
import java.time.Instant;
import java.util.ArrayList;
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 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.CorrectionSuggestion;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
import javafx.application.Platform;
/**
* Monocle-basierte headless Smoke-Tests für {@link GuiCorrectionDialogCoordinator}.
* <p>
* Prüft folgende Szenarien:
* <ul>
* <li>Bericht mit korrigierbaren Befunden Dialog wird angefragt, bei Bestätigung
* werden Korrekturen ausgeführt, Meldungen erscheinen.</li>
* <li>Bericht ohne korrigierbare Befunde kein Dialog, keine Korrekturen.</li>
* <li>Bei Dialog-Abbruch keine Korrekturen, keine Meldungen mit Source Korrekturen".</li>
* </ul>
* <p>
* Der {@code correctionThreadFactory} wird auf synchrone Ausführung umgestellt und
* {@code resultDelivery} auf direkten Aufruf, damit Ergebnisse sofort nach
* {@code offerCorrections()} verfügbar sind.
*/
class GuiCorrectionDialogCoordinatorSmokeTest {
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 must start within timeout");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Existing JavaFX Platform must be reachable within timeout");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Shared platform do not call Platform.exit().
}
// =========================================================================
// Scenario: report with correctable findings dialog shown, corrections applied
// =========================================================================
/**
* Smoke-Test: Bei korrigierbaren Befunden und Dialog-Bestätigung werden Korrekturen
* ausgeführt und als Meldungen eingehängt.
*/
@Test
void offerCorrections_withCorrectableFindings_dialogConfirmed_appliesCorrectionAndAddsMessages()
throws Exception {
runOnFx(() -> {
List<GuiMessageEntry> messages = new ArrayList<>();
AtomicBoolean correctionExecuted = new AtomicBoolean(false);
CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted);
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
service, messages, true /* confirm */);
TechnicalTestReport report = buildReportWithCorrectableFinding();
coordinator.offerCorrections(report);
assertTrue(correctionExecuted.get(),
"Korrektur muss nach Bestätigung ausgeführt worden sein");
long correctionEntries = messages.stream()
.filter(m -> m.source().isPresent()
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
.count();
assertTrue(correctionEntries > 0,
"Nach Ausführung müssen Meldungen mit Source '"
+ GuiCorrectionDialogCoordinator.SOURCE_TAG + "' vorhanden sein");
});
}
// =========================================================================
// Scenario: report without correctable findings no dialog, no corrections
// =========================================================================
/**
* Smoke-Test: Bericht ohne korrigierbare Befunde kein Dialog, keine Korrekturen.
*/
@Test
void offerCorrections_withoutCorrectableFindings_noDialogNoCorrections() throws Exception {
runOnFx(() -> {
List<GuiMessageEntry> messages = new ArrayList<>();
AtomicBoolean dialogShown = new AtomicBoolean(false);
AtomicBoolean correctionExecuted = new AtomicBoolean(false);
CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted);
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
service, messages, true /* confirm */);
coordinator.dialogSupplier = content -> {
dialogShown.set(true);
return true;
};
// Report with NO correctable findings (all succeed)
TechnicalTestReport report = buildReportWithNoCorrectableFindings();
coordinator.offerCorrections(report);
assertFalse(dialogShown.get(), "Kein Dialog darf angezeigt werden wenn keine Korrekturen möglich");
assertFalse(correctionExecuted.get(), "Keine Korrektur darf ausgeführt werden");
assertTrue(messages.isEmpty(), "Keine Meldungen dürfen hinzugefügt werden");
});
}
// =========================================================================
// Scenario: dialog cancelled no corrections, no messages with SOURCE_TAG
// =========================================================================
/**
* Smoke-Test: Dialog-Abbruch keine Korrekturen, keine Meldungen mit Source-Tag.
*/
@Test
void offerCorrections_dialogCancelled_noCorrectionsNoMessages() throws Exception {
runOnFx(() -> {
List<GuiMessageEntry> messages = new ArrayList<>();
AtomicBoolean correctionExecuted = new AtomicBoolean(false);
CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted);
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
service, messages, false /* cancel */);
TechnicalTestReport report = buildReportWithCorrectableFinding();
coordinator.offerCorrections(report);
assertFalse(correctionExecuted.get(),
"Bei Dialog-Abbruch dürfen keine Korrekturen ausgeführt werden");
long correctionEntries = messages.stream()
.filter(m -> m.source().isPresent()
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
.count();
assertEquals(0, correctionEntries,
"Bei Dialog-Abbruch dürfen keine Meldungen mit Source '"
+ GuiCorrectionDialogCoordinator.SOURCE_TAG + "' hinzugefügt werden");
});
}
// =========================================================================
// Scenario: replace semantics second run replaces previous SOURCE_TAG entries
// =========================================================================
/**
* Smoke-Test: Beim zweiten Aufruf werden vorherige SOURCE_TAG-Einträge ersetzt.
*/
@Test
void offerCorrections_calledTwice_replacesPreviousMessages() throws Exception {
runOnFx(() -> {
List<GuiMessageEntry> messages = new ArrayList<>();
AtomicBoolean ignored = new AtomicBoolean(false);
CorrectionExecutionService service = buildServiceThatTracksExecution(ignored);
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
service, messages, true);
TechnicalTestReport report = buildReportWithCorrectableFinding();
coordinator.offerCorrections(report);
long countAfterFirst = messages.stream()
.filter(m -> m.source().isPresent()
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
.count();
coordinator.offerCorrections(report);
long countAfterSecond = messages.stream()
.filter(m -> m.source().isPresent()
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
.count();
assertEquals(countAfterFirst, countAfterSecond,
"Zweiter Aufruf muss vorherige Einträge ersetzen (Replace-Semantik)");
});
}
// =========================================================================
// Helpers
// =========================================================================
/**
* Baut einen synchron laufenden {@link GuiCorrectionDialogCoordinator}.
* <p>
* Thread-Factory läuft inline; resultDelivery ist direkter Aufruf; dialogSupplier
* gibt den festen {@code confirm}-Wert zurück.
*/
private static GuiCorrectionDialogCoordinator buildSyncCoordinator(
CorrectionExecutionService service,
List<GuiMessageEntry> messages,
boolean confirm) {
GuiCorrectionDialogCoordinator coordinator = new GuiCorrectionDialogCoordinator(
service,
messages,
ignored -> { /* no-op refresh */ });
coordinator.dialogSupplier = content -> confirm;
coordinator.correctionThreadFactory = task -> new Thread(task, "sync-correction-thread") {
@Override
public void start() {
run(); // inline, synchronous
}
};
coordinator.resultDelivery = Runnable::run; // direct call, no FX queue
return coordinator;
}
/**
* Baut einen {@link CorrectionExecutionService}, der {@code correctionExecuted} auf {@code true}
* setzt, wenn eine Korrektur aufgerufen wird.
*/
private static CorrectionExecutionService buildServiceThatTracksExecution(
AtomicBoolean correctionExecuted) {
ResourceCreationPort trackingPort = new ResourceCreationPort() {
@Override
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) {
correctionExecuted.set(true);
return new CorrectionOutcome.Applied(s, "Angelegt");
}
@Override
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) {
correctionExecuted.set(true);
return new CorrectionOutcome.Applied(s, "Erzeugt");
}
@Override
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) {
correctionExecuted.set(true);
return new CorrectionOutcome.Applied(s, "Vorbereitet");
}
};
return new CorrectionExecutionService(trackingPort);
}
/** Baut einen Bericht mit einem korrigierbaren Fehler-Befund. */
private static TechnicalTestReport buildReportWithCorrectableFinding() {
CorrectionSuggestion.CreateDirectory suggestion =
new CorrectionSuggestion.CreateDirectory("C:/test/target", "Zielordner anlegen: C:/test/target");
CheckpointResult.Failure failure = new CheckpointResult.Failure(
CheckpointId.TARGET_FOLDER_USABLE,
CheckpointSeverity.ERROR,
"Zielordner nicht vorhanden",
Optional.of(suggestion));
return new TechnicalTestReport(List.of(failure), Instant.now());
}
/** Baut einen Bericht ohne korrigierbare Befunde (alles erfolgreich). */
private static TechnicalTestReport buildReportWithNoCorrectableFindings() {
CheckpointResult.Success success = new CheckpointResult.Success(
CheckpointId.TARGET_FOLDER_USABLE,
"Zielordner vorhanden");
return new TechnicalTestReport(List.of(success), Instant.now());
}
private static void runOnFx(ThrowingRunnable task) throws Exception {
java.util.concurrent.atomic.AtomicReference<Throwable> error =
new java.util.concurrent.atomic.AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
task.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX task must complete within timeout");
Throwable t = error.get();
if (t != null) {
if (t instanceof Exception ex) throw ex;
throw new AssertionError("Unexpected error", t);
}
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
}
@@ -174,6 +174,7 @@ class GuiDirtyStateTest {
v.maxRetriesTransient(), v.maxRetriesTransient(),
v.maxPages(), v.maxPages(),
v.maxTextCharacters(), v.maxTextCharacters(),
v.maxTitleLength(),
v.logAiSensitive(), v.logAiSensitive(),
v.activeProviderFamily(), v.activeProviderFamily(),
v.providerConfigurations()); v.providerConfigurations());
@@ -17,7 +17,6 @@ import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder; 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.GuiConfigurationTemplateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues; 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.GuiProviderConfigurationState;
@@ -92,8 +91,10 @@ class GuiEditorFieldBindingTest {
"Max retries must match the standard template default"); "Max retries must match the standard template default");
assertEquals("10", v.maxPages(), assertEquals("10", v.maxPages(),
"Max pages must match the standard template default"); "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"); "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(), assertEquals("false", v.logAiSensitive(),
"log.ai.sensitive must match the standard template default (false)"); "log.ai.sensitive must match the standard template default (false)");
}); });
@@ -201,11 +202,11 @@ class GuiEditorFieldBindingTest {
String originalSqlite = ws.editorState().values().sqliteFile(); String originalSqlite = ws.editorState().values().sqliteFile();
// Replace the file-picker hook: always return null (cancel). // 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. // Simulate button handler: null result means do nothing.
String picked = ws.filePickerDialog.apply("SQLite-Datei ausw\u00e4hlen", String picked = ws.filePickerDialog.pick("SQLite-Datei ausw\u00e4hlen",
ws.editorState().values().sqliteFile()); ws.editorState().values().sqliteFile(), java.util.List.of());
if (picked != null) { if (picked != null) {
ws.editorState = ws.editorState() ws.editorState = ws.editorState()
.withValues(ws.editorState().values().withSqliteFile(picked)); .withValues(ws.editorState().values().withSqliteFile(picked));
@@ -322,7 +323,36 @@ class GuiEditorFieldBindingTest {
GuiConfigurationTemplateFactory.createStandardTemplate(), GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(), Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
capturingWriter); capturingWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context); GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
ws.requestNewConfiguration(); ws.requestNewConfiguration();
@@ -395,6 +425,7 @@ class GuiEditorFieldBindingTest {
.withMaxRetriesTransient("5") .withMaxRetriesTransient("5")
.withMaxPages("20") .withMaxPages("20")
.withMaxTextCharacters("1000") .withMaxTextCharacters("1000")
.withMaxTitleLength("80")
.withLogAiSensitive("true") .withLogAiSensitive("true")
.withActiveProviderFamily("openai-compatible"); .withActiveProviderFamily("openai-compatible");
@@ -408,6 +439,7 @@ class GuiEditorFieldBindingTest {
assertEquals("5", modified.maxRetriesTransient()); assertEquals("5", modified.maxRetriesTransient());
assertEquals("20", modified.maxPages()); assertEquals("20", modified.maxPages());
assertEquals("1000", modified.maxTextCharacters()); assertEquals("1000", modified.maxTextCharacters());
assertEquals("80", modified.maxTitleLength());
assertEquals("true", modified.logAiSensitive()); assertEquals("true", modified.logAiSensitive());
assertEquals("openai-compatible", modified.activeProviderFamily()); assertEquals("openai-compatible", modified.activeProviderFamily());
@@ -2,6 +2,7 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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 static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException; import java.io.IOException;
@@ -14,17 +15,16 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; 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.AfterAll;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import 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. * Integration tests for the GUI startup context and configuration loading path.
* <p> * <p>
@@ -115,7 +115,36 @@ class GuiEditorIntegrationTest {
GuiConfigurationEditorState loadedState = fileLoader.load(configFile); GuiConfigurationEditorState loadedState = fileLoader.load(configFile);
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path); GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
GuiStartupContext context = new GuiStartupContext(loadedState, Optional.empty(), fileLoader, noOpWriter); GuiStartupContext context = new GuiStartupContext(loadedState, Optional.empty(), fileLoader, noOpWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
AtomicReference<Throwable> error = new AtomicReference<>(); AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
@@ -168,14 +197,14 @@ class GuiEditorIntegrationTest {
// ========================================================================= // =========================================================================
/** /**
* Verifies that starting the GUI without a {@code --config} argument produces the defined * Verifies that starting the GUI without a {@code --config} argument shows the standard
* blank welcome state: header path is empty, welcome guidance is visible, and the editor is * template defaults immediately: header path is empty, welcome guidance is hidden, the
* not in dirty state. * editor is not in dirty state, and the standard default values are populated.
* *
* @throws Exception if the FX thread task fails or times out * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
void guiStartup_withoutConfigPath_showsBlankWelcomeState() throws Exception { void guiStartup_withoutConfigPath_showsStandardTemplateDefaults() throws Exception {
GuiStartupContext blankContext = GuiStartupContext.blank(Optional.empty()); GuiStartupContext blankContext = GuiStartupContext.blank(Optional.empty());
AtomicReference<Throwable> error = new AtomicReference<>(); AtomicReference<Throwable> error = new AtomicReference<>();
@@ -187,14 +216,14 @@ class GuiEditorIntegrationTest {
assertEquals("", workspace.configurationPathText(), assertEquals("", workspace.configurationPathText(),
"Header path must be empty when no configuration is loaded"); "Header path must be empty when no configuration is loaded");
assertTrue(workspace.isWelcomeGuidanceVisible(), assertFalse(workspace.isWelcomeGuidanceVisible(),
"Welcome guidance must be visible when no configuration is loaded"); "Welcome guidance must stay hidden because the standard template is shown immediately");
assertFalse(workspace.editorState().hasLoadedFileSnapshot(), 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(), assertFalse(workspace.editorState().isDirty(),
"Blank start state must not be dirty"); "Default start state must not be dirty");
assertTrue(workspace.welcomeText().contains("Willkommen"), assertEquals("./work/local/source", workspace.editorState().values().sourceFolder(),
"Welcome text must be shown in German"); "Default start state must populate the standard source folder");
} catch (Throwable t) { } catch (Throwable t) {
error.set(t); error.set(t);
@@ -237,7 +266,36 @@ class GuiEditorIntegrationTest {
blankState, blankState,
Optional.of(notice), Optional.of(notice),
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(), configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
noOpWriter); noOpWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
AtomicReference<Throwable> error = new AtomicReference<>(); AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
@@ -246,8 +304,8 @@ class GuiEditorIntegrationTest {
try { try {
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context); GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
assertTrue(workspace.isWelcomeGuidanceVisible(), assertFalse(workspace.isWelcomeGuidanceVisible(),
"Welcome guidance must be visible when config path does not exist"); "Welcome guidance must stay hidden because the standard template is shown immediately");
assertEquals("", workspace.configurationPathText(), assertEquals("", workspace.configurationPathText(),
"Header path must be empty when config file was not found"); "Header path must be empty when config file was not found");
assertFalse(workspace.editorState().hasLoadedFileSnapshot(), assertFalse(workspace.editorState().hasLoadedFileSnapshot(),
@@ -267,6 +325,96 @@ class GuiEditorIntegrationTest {
} }
} }
// =========================================================================
// GUI startup with a non-existent --config path: startup notice rendered in header
// =========================================================================
/**
* Verifies that when the workspace is constructed with a startup notice (as Bootstrap does
* when {@code --config} points to a non-existent file), the notice text is rendered in the
* visible header status label.
* <p>
* This complements {@link #guiStartup_withNonExistentConfigPath_usesBlankStateAndCarriesStartupNotice}
* which only verifies the blank editor state. This test verifies the user-visible side: the
* notice must be rendered so the user can read it.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void guiStartup_withNonExistentConfigPath_noticeIsRenderedInHeaderStatusLabel()
throws Exception {
String notice = "Konfigurationsdatei nicht gefunden: /no/such/file.properties\n"
+ "Die GUI startet ohne Konfigurationsdatei.";
GuiConfigurationEditorState blankState = GuiConfigurationEditorStateFactory.createBlankStartState();
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
GuiStartupContext context = new GuiStartupContext(
blankState,
Optional.of(notice),
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
noOpWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
// The startup notice must be rendered in the visible header status label.
javafx.scene.control.Label noticeLabel = workspace.statusNoticeLabel();
assertNotNull(noticeLabel, "Header status label must not be null");
assertTrue(noticeLabel.isVisible(),
"Header status label must be visible when a startup notice is present");
assertTrue(noticeLabel.isManaged(),
"Header status label must be managed when a startup notice is present");
assertFalse(noticeLabel.getText().isBlank(),
"Header status label text must not be blank when startup notice is present");
assertTrue(noticeLabel.getText().contains("Konfigurationsdatei nicht gefunden"),
"Header status label must contain the notice text; got: " + noticeLabel.getText());
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX task must complete within timeout");
if (error.get() != null) {
throw new AssertionError("FX thread threw an exception", error.get());
}
}
// ========================================================================= // =========================================================================
// --config path resolution: static helper (no FX thread needed) // --config path resolution: static helper (no FX thread needed)
// ========================================================================= // =========================================================================
@@ -320,7 +468,7 @@ class GuiEditorIntegrationTest {
+ "sqlite.file=./work/test.db\n" + "sqlite.file=./work/test.db\n"
+ "max.retries.transient=3\n" + "max.retries.transient=3\n"
+ "max.pages=10\n" + "max.pages=10\n"
+ "max.text.characters=5000\n" + "max.text.characters=1000\n"
+ "prompt.template.file=./config/prompt.txt\n"; + "prompt.template.file=./config/prompt.txt\n";
Files.writeString(path, content, StandardCharsets.UTF_8); Files.writeString(path, content, StandardCharsets.UTF_8);
} }
@@ -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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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 static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException; import java.io.IOException;
@@ -18,15 +17,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier; 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.AfterAll;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer; 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.TestMethodOrder;
import org.junit.jupiter.api.io.TempDir; 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. * Regression smoke tests for the complete editor workflow.
* <p> * <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> * <p>
* The workspace must display the welcome guidance, the header path must be empty, and * The workspace must keep the welcome guidance hidden because the standard template values
* the editor state must not have a file snapshot. "Neu" and "Öffnen" must be present. * 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 * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
@Order(1) @Order(1)
void guiStart_withoutConfig_showsBlankWelcomeStateAndExposesNeuAndOeffnenButtons() void guiStart_withoutConfig_showsStandardTemplateDefaultsAndExposesNeuAndOeffnenButtons()
throws Exception { throws Exception {
runOnFx(() -> { runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty()); GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
assertTrue(ws.isWelcomeGuidanceVisible(), assertFalse(ws.isWelcomeGuidanceVisible(),
"Welcome guidance must be visible on blank start"); "Welcome guidance must stay hidden because the standard template is shown immediately");
assertEquals("", ws.configurationPathText(), assertEquals("", ws.configurationPathText(),
"Header path must be empty on blank start"); "Header path must be empty on default start");
assertFalse(ws.editorState().hasLoadedFileSnapshot(), assertFalse(ws.editorState().hasLoadedFileSnapshot(),
"No file snapshot must exist on blank start"); "No file snapshot must exist on default start");
assertFalse(ws.editorState().isDirty(), assertFalse(ws.editorState().isDirty(),
"Blank start state must not be dirty"); "Default start state must not be dirty");
assertEquals("Neu", ws.newButton().getText(), assertEquals("Neu", ws.newButton().getText(),
"'Neu' button must be present"); "'Neu' button must be present");
assertEquals("Öffnen", ws.openButton().getText(), assertEquals("Öffnen", ws.openButton().getText(),
@@ -133,23 +132,24 @@ class GuiEditorRegressionSmokeTest {
// ========================================================================= // =========================================================================
/** /**
* Regression: "Neu" switches the workspace to the standard template, hides the welcome * Regression: "Neu" reloads the standard template values, keeps the welcome guidance
* guidance, and leaves the state clean with all template fields populated. * hidden, and leaves the state clean with all template fields populated.
* *
* @throws Exception if the FX thread task fails or times out * @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
@Order(2) @Order(2)
void neu_withStandardTemplate_populatesFieldsAndHidesWelcomeGuidance() throws Exception { void neu_withStandardTemplate_populatesFieldsAndKeepsWelcomeHidden() throws Exception {
runOnFx(() -> { runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty()); 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(); ws.requestNewConfiguration();
assertFalse(ws.isWelcomeGuidanceVisible(), assertFalse(ws.isWelcomeGuidanceVisible(),
"Welcome guidance must be hidden after 'Neu'"); "Welcome guidance must remain hidden after 'Neu'");
assertEquals("", ws.editorState().configurationPathText(), assertEquals("", ws.editorState().configurationPathText(),
"Path must remain empty after 'Neu' (no file saved yet)"); "Path must remain empty after 'Neu' (no file saved yet)");
assertFalse(ws.editorState().isDirty(), assertFalse(ws.editorState().isDirty(),
@@ -186,7 +186,36 @@ class GuiEditorRegressionSmokeTest {
GuiConfigurationFileLoader loader = buildSnapshotLoader(); GuiConfigurationFileLoader loader = buildSnapshotLoader();
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path); GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
GuiConfigurationEditorState initialState = GuiConfigurationEditorStateFactory.createBlankStartState(); GuiConfigurationEditorState initialState = GuiConfigurationEditorStateFactory.createBlankStartState();
GuiStartupContext context = new GuiStartupContext(initialState, Optional.empty(), loader, noOpWriter); GuiStartupContext context = new GuiStartupContext(initialState, Optional.empty(), loader, noOpWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>(); AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
AtomicReference<Throwable> error = new AtomicReference<>(); AtomicReference<Throwable> error = new AtomicReference<>();
@@ -297,7 +326,36 @@ class GuiEditorRegressionSmokeTest {
stateWithFile, stateWithFile,
Optional.empty(), Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
capturingWriter); capturingWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
CountDownLatch setupLatch = new CountDownLatch(1); CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> { Platform.runLater(() -> {
@@ -393,7 +451,36 @@ class GuiEditorRegressionSmokeTest {
GuiConfigurationTemplateFactory.createStandardTemplate(), GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(), Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
capturingWriter); capturingWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
CountDownLatch setupLatch = new CountDownLatch(1); CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> { Platform.runLater(() -> {
@@ -493,7 +580,36 @@ class GuiEditorRegressionSmokeTest {
GuiConfigurationTemplateFactory.createStandardTemplate(), GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(), Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
capturingWriter); capturingWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
CountDownLatch setupLatch = new CountDownLatch(1); CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> { Platform.runLater(() -> {
@@ -564,7 +680,36 @@ class GuiEditorRegressionSmokeTest {
GuiConfigurationTemplateFactory.createStandardTemplate(), GuiConfigurationTemplateFactory.createStandardTemplate(),
Optional.empty(), Optional.empty(),
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
trackingWriter); trackingWriter,
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
CountDownLatch setupLatch = new CountDownLatch(1); CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> { Platform.runLater(() -> {
@@ -684,7 +829,6 @@ class GuiEditorRegressionSmokeTest {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty()); GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration(); ws.requestNewConfiguration();
String originalSource = ws.editorState().values().sourceFolder();
GuiConfigurationValues dirty = ws.editorState().values() GuiConfigurationValues dirty = ws.editorState().values()
.withSourceFolder("./dirty-source"); .withSourceFolder("./dirty-source");
ws.editorState = ws.editorState().withValues(dirty); ws.editorState = ws.editorState().withValues(dirty);
@@ -767,7 +911,7 @@ class GuiEditorRegressionSmokeTest {
+ "sqlite.file=./work/test.db\n" + "sqlite.file=./work/test.db\n"
+ "max.retries.transient=3\n" + "max.retries.transient=3\n"
+ "max.pages=10\n" + "max.pages=10\n"
+ "max.text.characters=5000\n" + "max.text.characters=1000\n"
+ "prompt.template.file=./config/prompt.txt\n"; + "prompt.template.file=./config/prompt.txt\n";
Files.writeString(path, content, StandardCharsets.UTF_8); Files.writeString(path, content, StandardCharsets.UTF_8);
} }
@@ -0,0 +1,663 @@
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.assertTrue;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.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 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.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>
* These tests verify that the workspace triggers validation automatically when the editor
* state changes (via {@code applyEditorState} and {@code updateValues}) and that the
* {@link GuiEditorValidationResult} returned by {@code lastValidationResult()} reflects the
* current editor state.
*
* <h2>Covered scenarios</h2>
* <ul>
* <li>Opening an incomplete configuration (missing active provider) produces ERROR findings
* in {@code lastValidationResult} after the file is loaded.</li>
* <li>Opening an incomplete configuration populates {@code pendingFieldFindings} with a
* 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);
* 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>
*
* <h2>Threading</h2>
* <p>
* All workspace interactions run on the FX Application Thread via {@link Platform#runLater}.
* The {@code openConfigurationFile} method uses a background thread internally; tests that use
* it await file-load completion via a polling helper before verifying results.
* The Monocle headless configuration is activated by the Surefire JVM arguments.
*/
class GuiEditorValidationSmokeTest {
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 must start within timeout");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Existing JavaFX Platform must be reachable within timeout");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Shared platform do not call Platform.exit().
}
// =========================================================================
// Scenario: opening an incomplete configuration produces ERROR findings
// =========================================================================
/**
* Smoke test: when a properties file with an unknown (or empty) active-provider value is
* opened via {@link GuiConfigurationEditorWorkspace#openConfigurationFile}, the workspace
* calls {@code applyEditorState} after loading and runs validation automatically.
* <p>
* The resulting {@code lastValidationResult} must contain at least one ERROR because the
* active-provider field is empty.
*
* @param tempDir JUnit-provided temporary directory
* @throws Exception if the FX thread task fails or times out
*/
@Test
void openingIncompleteConfiguration_validationRunsAndProducesErrors(@TempDir Path tempDir)
throws Exception {
// Write a properties file with an empty active provider.
Path configFile = tempDir.resolve("incomplete.properties");
writePropertiesFile(configFile, "" /* empty active provider */);
GuiConfigurationFileLoader loader = buildSnapshotLoader();
GuiConfigurationEditorState blankState =
GuiConfigurationEditorStateFactory.createBlankStartState();
GuiStartupContext ctx = new GuiStartupContext(
blankState, Optional.empty(), loader,
(values, path) -> GuiConfigurationSaveResult.saved(path),
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
AtomicReference<Throwable> error = new AtomicReference<>();
// Create workspace and trigger file load on the FX thread.
CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
wsRef.set(ws);
ws.openConfigurationFile(configFile);
} catch (Throwable t) {
error.set(t);
} finally {
setupLatch.countDown();
}
});
assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Setup must complete within timeout");
rethrow(error);
// Wait for the background loader thread to apply the state.
waitFor(() -> {
AtomicBoolean ready = new AtomicBoolean(false);
CountDownLatch check = new CountDownLatch(1);
Platform.runLater(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
ready.set(true);
}
check.countDown();
});
try {
check.await(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return ready.get();
}, FX_TIMEOUT_SECONDS);
// Verify validation result on the FX thread.
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = wsRef.get();
GuiEditorValidationResult result = ws.lastValidationResult();
assertNotNull(result, "lastValidationResult must never be null");
assertTrue(result.hasErrors(),
"Loading a config with empty active provider must produce ERROR findings");
assertTrue(result.hasFieldFindingFor("ai.provider.active"),
"pendingFieldFindings must contain a finding for 'ai.provider.active'"
+ " when the active provider is empty in the loaded file");
} catch (Throwable t) {
error.set(t);
} finally {
verifyLatch.countDown();
}
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Verify latch must complete within timeout");
rethrow(error);
}
// =========================================================================
// Scenario: changing a field updates the validation result
// =========================================================================
/**
* Smoke test: when the active provider is changed from a valid value to an empty string via
* a direct state update followed by {@code requestNewConfiguration} (which calls
* {@code applyEditorState} and triggers {@code runEditorValidation}), the
* {@code lastValidationResult} is updated with findings that reflect the new state.
* <p>
* More concretely, this test demonstrates the field-changere-validation flow by:
* <ol>
* <li>Starting with the standard template (valid provider no provider error).</li>
* <li>Loading a file that has an empty provider (produces a provider ERROR).</li>
* <li>Verifying that {@code lastValidationResult} changed from "no error" to "error" as
* the result of loading the file with invalid values.</li>
* </ol>
*
* @param tempDir JUnit-provided temporary directory
* @throws Exception if the FX thread task fails or times out
*/
@Test
void changingField_revalidatesAndUpdatesLastValidationResult(@TempDir Path tempDir)
throws Exception {
Path invalidConfig = tempDir.resolve("invalid-provider.properties");
writePropertiesFile(invalidConfig, "" /* empty active provider */);
GuiConfigurationFileLoader loader = buildSnapshotLoader();
GuiConfigurationEditorState blankState =
GuiConfigurationEditorStateFactory.createBlankStartState();
GuiStartupContext ctx = new GuiStartupContext(
blankState, Optional.empty(), loader,
(values, path) -> GuiConfigurationSaveResult.saved(path),
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(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch setupLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
wsRef.set(ws);
// Step 1: apply template validation runs with valid values.
ws.requestNewConfiguration();
} catch (Throwable t) {
error.set(t);
} finally {
setupLatch.countDown();
}
});
assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Setup timeout");
rethrow(error);
// Confirm valid state after template.
CountDownLatch checkValidLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = wsRef.get();
assertFalse(ws.lastValidationResult().hasFieldFindingFor("ai.provider.active"),
"After 'Neu' with valid template the active-provider field must have no error");
} catch (Throwable t) {
error.set(t);
} finally {
checkValidLatch.countDown();
}
});
assertTrue(checkValidLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Check timeout");
rethrow(error);
// Step 2: trigger field change by loading an invalid config file.
CountDownLatch loadLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
wsRef.get().openConfigurationFile(invalidConfig);
} catch (Throwable t) {
error.set(t);
} finally {
loadLatch.countDown();
}
});
assertTrue(loadLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Load trigger timeout");
rethrow(error);
// Wait for background loader.
waitFor(() -> {
AtomicBoolean ready = new AtomicBoolean(false);
CountDownLatch check = new CountDownLatch(1);
Platform.runLater(() -> {
GuiConfigurationEditorWorkspace ws = wsRef.get();
if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
ready.set(true);
}
check.countDown();
});
try {
check.await(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return ready.get();
}, FX_TIMEOUT_SECONDS);
// Verify: invalid provider is now detected.
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace ws = wsRef.get();
GuiEditorValidationResult result = ws.lastValidationResult();
assertTrue(result.hasErrors(),
"After loading a config with empty active provider, result must have errors");
assertTrue(result.hasFieldFindingFor("ai.provider.active"),
"After loading invalid config, active-provider finding must be present");
} catch (Throwable t) {
error.set(t);
} finally {
verifyLatch.countDown();
}
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Verify timeout");
rethrow(error);
}
// =========================================================================
// Scenario: standard template validation WARNING for max.text.characters
// =========================================================================
/**
* Smoke test: after {@code requestNewConfiguration}, the standard template values are active
* 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_triggersValidationAndLoadsTemplate()
throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
GuiEditorValidationResult result = ws.lastValidationResult();
assertNotNull(result, "lastValidationResult must not be null after 'Neu'");
// Template has valid provider no field finding for ai.provider.active.
assertFalse(result.hasFieldFindingFor("ai.provider.active"),
"Standard template has a valid provider; 'ai.provider.active' must have"
+ " no field finding");
// 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");
});
}
// =========================================================================
// Scenario: pendingFieldFindings updated by applyEditorState
// =========================================================================
/**
* Smoke test: after {@code requestNewConfiguration}, the {@code pendingFieldFindings} list is
* updated and the template's valid provider is not flagged.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void requestNewConfiguration_pendingFieldFindings_noProviderError() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
assertNotNull(ws.pendingFieldFindings, "pendingFieldFindings must never be null");
assertFalse(ws.pendingFieldFindings.stream()
.anyMatch(f -> "ai.provider.active".equals(f.fieldKey())),
"Standard template has a valid provider; no field finding expected for"
+ " 'ai.provider.active'");
});
}
// =========================================================================
// 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
// =========================================================================
private static GuiConfigurationFileLoader buildSnapshotLoader() {
return path -> {
try {
String content = Files.readString(path, StandardCharsets.UTF_8);
Properties props = new Properties();
props.load(new StringReader(content));
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(path, props);
return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(
snapshot, Optional.empty());
} catch (IOException e) {
throw new GuiConfigurationLoadException("Failed to load " + path, e);
}
};
}
private static void writePropertiesFile(Path path, String activeProvider) throws IOException {
String content = "source.folder=./work/source\n"
+ "target.folder=./work/target\n"
+ "ai.provider.active=" + activeProvider + "\n"
+ "sqlite.file=./work/test.db\n"
+ "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);
}
private static void runOnFx(ThrowingRunnable task) throws Exception {
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
task.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX task must complete within timeout");
rethrow(error);
}
private static void rethrow(AtomicReference<Throwable> error) throws Exception {
Throwable t = error.get();
if (t == null) {
return;
}
if (t instanceof Exception ex) {
throw ex;
}
throw new AssertionError("Unexpected error", t);
}
private static void waitFor(BooleanSupplier condition, long timeoutSeconds)
throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (!condition.getAsBoolean()) {
assertTrue(System.currentTimeMillis() < deadline,
"Condition was not met within the timeout");
Thread.sleep(50);
}
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
}
@@ -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());
}
}
}
@@ -0,0 +1,666 @@
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.time.Instant;
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.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;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
/**
* 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.
*
* <p>All tests run on the JavaFX Application Thread under Monocle headless. The model catalogue
* port is replaced with a synchronous stub so no real HTTP calls are made and the tests are
* fully deterministic.
*
* <p>The coordinator's thread factory and result-delivery mechanism are both replaced with
* synchronous implementations so retrieval and result application happen inline on the calling
* thread (the FX thread in these tests). This avoids any async boundary and makes assertions
* immediately consistent after each trigger call.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class GuiModelCatalogSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
// =========================================================================
// JavaFX Platform lifecycle
// =========================================================================
@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 must start within timeout");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Existing JavaFX Platform must be reachable within timeout");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Shared platform do not call Platform.exit().
}
// =========================================================================
// Test: Success result ComboBox shown, first model pre-selected
// =========================================================================
/**
* When the model catalogue port returns a {@link ModelCatalogResult.Success}, the provider
* block's model field must switch to a non-editable ComboBox pre-selecting the first model.
*/
@Test
@Order(1)
void successResult_comboBoxIsShownWithFirstModelSelected() throws Exception {
List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
"Source must be LIST_REMOTE_SUCCESS after successful retrieval");
assertEquals("claude-3-5-sonnet", container.currentModelValue(),
"First model must be pre-selected");
});
}
// =========================================================================
// Test: EmptyList result TextField shown
// =========================================================================
/**
* When the model catalogue port returns {@link ModelCatalogResult.EmptyList}, the provider
* block's model field must show the manual text field.
*/
@Test
@Order(2)
void emptyListResult_textFieldIsShown() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.EmptyList(req.providerIdentifier(), Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT, container.currentSource(),
"Source must be LIST_UNAVAILABLE_MANUAL_INPUT for EmptyList result");
});
}
// =========================================================================
// Test: IncompleteConfiguration result TextField shown
// =========================================================================
/**
* When the model catalogue port returns {@link ModelCatalogResult.IncompleteConfiguration},
* the provider block's model field must show the manual text field.
*/
@Test
@Order(3)
void incompleteConfigResult_textFieldIsShown() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "API-Key fehlt.");
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT, container.currentSource(),
"Source must be LIST_UNAVAILABLE_MANUAL_INPUT for IncompleteConfiguration");
});
}
// =========================================================================
// Test: TechnicalFailure result TextField shown with FAILED state
// =========================================================================
/**
* When the model catalogue port returns {@link ModelCatalogResult.TechnicalFailure},
* the provider block's model field must show the manual text field in the failed state.
*/
@Test
@Order(4)
void technicalFailureResult_textFieldIsShownWithFailedState() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.TechnicalFailure(req.providerIdentifier(), "HTTP_ERROR",
"Status 503");
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_FAILED_MANUAL_INPUT, container.currentSource(),
"Source must be LIST_FAILED_MANUAL_INPUT for TechnicalFailure");
});
}
// =========================================================================
// Test: Manual value discarded when not in new list
// =========================================================================
/**
* When a manual model name is present in the text field and a subsequent successful
* retrieval returns a list that does NOT contain that name, the value must be discarded
* and the first item in the new list must be selected.
*/
@Test
@Order(5)
void successResult_manualValueDiscardedWhenNotInList() throws Exception {
List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
// Pre-set a manual value not present in the incoming list.
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container);
container.setTextFieldValue("my-custom-model");
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
"Source must be LIST_REMOTE_SUCCESS after successful retrieval");
assertEquals("claude-3-5-sonnet", container.currentModelValue(),
"Manual value not in list must be discarded; first list item selected");
});
}
// =========================================================================
// Test: Manual value preserved when present in new list
// =========================================================================
/**
* When a manual model name is present in the text field and a subsequent successful
* retrieval returns a list that DOES contain that name, the selection must be preserved.
*/
@Test
@Order(6)
void successResult_manualValuePreservedWhenInList() throws Exception {
List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container);
// Pre-set a value that IS in the incoming list.
container.setTextFieldValue("claude-3-haiku");
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
"Source must be LIST_REMOTE_SUCCESS");
assertEquals("claude-3-haiku", container.currentModelValue(),
"Manual value present in list must be preserved as selection");
});
}
// =========================================================================
// Test: Provider switch triggers automatic model retrieval
// =========================================================================
/**
* Switching the provider ComboBox must automatically trigger a model retrieval for the
* newly selected provider without requiring the user to press "Modelle neu laden".
*/
@Test
@Order(7)
void providerSwitch_triggersAutomaticModelRetrieval() throws Exception {
List<String> openAiModels = List.of("gpt-4o", "gpt-4-turbo");
AiModelCatalogPort stub = req -> {
if (AiProviderFamily.OPENAI_COMPATIBLE.getIdentifier().equals(req.providerIdentifier())) {
return new ModelCatalogResult.Success(req.providerIdentifier(), openAiModels,
Instant.now());
}
return new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(),
"Test-Stub: kein Claude-Abruf in diesem Test.");
};
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
// Switch the provider ComboBox from Claude to OpenAI; the listener auto-triggers retrieval.
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
assertNotNull(comboBox, "Provider ComboBox must be present");
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
// Because resultDelivery is synchronous, retrieval and result application happened inline.
GuiModelFieldContainer openAiContainer =
ws.modelFieldContainers.get(AiProviderFamily.OPENAI_COMPATIBLE);
assertNotNull(openAiContainer, "OpenAI model field container must be present");
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, openAiContainer.currentSource(),
"OpenAI model source must be LIST_REMOTE_SUCCESS after automatic retrieval on switch");
assertEquals("gpt-4o", openAiContainer.currentModelValue(),
"First OpenAI model must be pre-selected after automatic retrieval");
});
}
// =========================================================================
// Test: "Modelle neu laden" button triggers retrieval
// =========================================================================
/**
* Pressing the "Modelle neu laden" button must trigger the same retrieval path as the
* automatic trigger on provider switch.
*/
@Test
@Order(8)
void reloadModelsButton_triggersModelRetrieval() throws Exception {
List<String> models = List.of("claude-opus-4");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
Button reloadButton = findNodeById(ws, "modelle-neu-laden-button", Button.class);
assertNotNull(reloadButton, "\"Modelle neu laden\" button must be present in the scene graph");
reloadButton.fire();
// Because resultDelivery is synchronous, result is applied immediately.
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
assertNotNull(container, "Claude model field container must be present");
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
"Source must be LIST_REMOTE_SUCCESS after pressing \"Modelle neu laden\"");
assertEquals("claude-opus-4", container.currentModelValue(),
"Model returned by stub must be selected after reload");
});
}
// =========================================================================
// Test: pendingMessages list receives entry after each retrieval
// =========================================================================
/**
* After each model catalogue retrieval a {@link GuiMessageEntry} must be appended to
* {@link GuiConfigurationEditorWorkspace#pendingMessages}, regardless of the result type.
*/
@Test
@Order(9)
void pendingMessages_entryAppendedAfterEachRetrieval() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.TechnicalFailure(req.providerIdentifier(), "TIMEOUT",
"Zeitüberschreitung");
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
int before = ws.pendingMessages.size();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertEquals(before + 1, ws.pendingMessages.size(),
"Exactly one message entry must be appended after retrieval");
GuiMessageEntry entry = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
assertEquals(GuiMessageSeverity.ERROR, entry.severity(),
"TechnicalFailure must produce an ERROR message entry");
assertTrue(entry.source().isPresent(), "Message must have a source label");
assertEquals("Modellabruf", entry.source().get(),
"Message source must be \"Modellabruf\"");
});
}
// =========================================================================
// Test: Success pendingMessage has INFO severity
// =========================================================================
/**
* A successful model list retrieval must append a message entry with {@link GuiMessageSeverity#INFO}.
*/
@Test
@Order(10)
void pendingMessages_successProducesInfoEntry() throws Exception {
List<String> models = List.of("claude-3-5-sonnet");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
assertEquals(GuiMessageSeverity.INFO, last.severity(),
"Successful retrieval must produce an INFO message");
});
}
// =========================================================================
// Test: IncompleteConfiguration pendingMessage has WARNING severity
// =========================================================================
/**
* An {@link ModelCatalogResult.IncompleteConfiguration} result must append a
* {@link GuiMessageSeverity#WARNING} entry.
*/
@Test
@Order(11)
void pendingMessages_incompleteConfigProducesWarningEntry() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(),
"Kein API-Key.");
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
assertEquals(GuiMessageSeverity.WARNING, last.severity(),
"IncompleteConfiguration must produce a WARNING message");
});
}
// =========================================================================
// Test: EmptyList pendingMessage has HINT severity
// =========================================================================
/**
* An {@link ModelCatalogResult.EmptyList} result must append a
* {@link GuiMessageSeverity#HINT} entry.
*/
@Test
@Order(12)
void pendingMessages_emptyListProducesHintEntry() throws Exception {
AiModelCatalogPort stub = req ->
new ModelCatalogResult.EmptyList(req.providerIdentifier(), Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
assertEquals(GuiMessageSeverity.HINT, last.severity(),
"EmptyList must produce a HINT message");
});
}
// =========================================================================
// Test: repeated retrieval replaces previous message entry, not accumulates
// =========================================================================
/**
* Triggering model retrieval twice must not accumulate two "Modellabruf" entries in
* {@code pendingMessages}. The second trigger must replace the entry from the first trigger
* so that exactly one entry with source "Modellabruf" is present after both calls.
* <p>
* This verifies the fix that removes old "Modellabruf" entries at the start of
* {@code applyResult} before appending the new one.
*/
@Test
@Order(13)
void pendingMessages_repeatedRetrieval_replacesNotAccumulates() throws Exception {
List<String> models = List.of("claude-3-5-sonnet");
AiModelCatalogPort stub = req ->
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
ws.requestNewConfiguration();
// Trigger retrieval twice for the same provider.
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
long modellabrufCount = ws.pendingMessages.stream()
.filter(m -> m.source().isPresent()
&& "Modellabruf".equals(m.source().get()))
.count();
assertEquals(1L, modellabrufCount,
"After two retrieval triggers, exactly one 'Modellabruf' entry must remain in"
+ " pendingMessages (replace semantics, not accumulate)");
});
}
// =========================================================================
// Helpers: workspace creation with stub
// =========================================================================
/**
* Creates a workspace whose model catalogue coordinator is backed by the given stub port.
* Both the thread factory and result delivery are replaced with synchronous implementations
* so retrieval and result application happen inline without any async boundary.
*
* @param stub the stub port returning deterministic results; must not be {@code null}
* @return a workspace ready for testing; never {@code null}
*/
private static GuiConfigurationEditorWorkspace createWorkspaceWithStub(AiModelCatalogPort stub) {
GuiStartupContext ctx = new GuiStartupContext(
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory
.createBlankStartState(),
Optional.empty(),
path -> de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory
.createBlankStartState(),
(values, path) -> GuiConfigurationSaveResult.saved(path),
stub,
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
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()),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
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; }
@Override public boolean isFileReadable(String p) { return false; }
@Override public boolean isSqlitePathUsable(String p) { return false; }
},
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()),
() -> 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"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
}));
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
// Synchronous thread factory: run the task directly instead of starting an OS thread.
ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(task, "gui-model-catalog-test") {
@Override
public synchronized void start() {
run(); // run synchronously on the calling thread
}
};
// Synchronous result delivery: execute the callback directly instead of via Platform.runLater.
ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
return ws;
}
/**
* Triggers model retrieval for the given family using the current editor state.
* Because the coordinator uses synchronous delivery, the result is applied immediately.
*
* @param ws the workspace to trigger retrieval on; must not be {@code null}
* @param family the provider family to retrieve models for; must not be {@code null}
*/
private static void triggerRetrieval(GuiConfigurationEditorWorkspace ws, AiProviderFamily family) {
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState pState =
Optional.ofNullable(ws.editorState().values().providerConfiguration(family))
.orElse(de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState
.blank());
ws.modelCatalogCoordinator.triggerModelRetrieval(family, pState);
}
// =========================================================================
// Helpers: scene graph traversal
// =========================================================================
@SuppressWarnings("unchecked")
private static ComboBox<AiProviderFamily> findProviderComboBox(GuiConfigurationEditorWorkspace ws) {
return (ComboBox<AiProviderFamily>) findNodeDeep(ws.tabPane, ComboBox.class);
}
@SuppressWarnings("unchecked")
private static <T extends Node> T findNodeById(GuiConfigurationEditorWorkspace ws,
String id, Class<T> type) {
return (T) findNodeByIdDeep(ws.tabPane, id);
}
private static Node findNodeByIdDeep(Node root, String id) {
if (id.equals(root.getId())) {
return root;
}
if (root instanceof javafx.scene.control.ScrollPane sp) {
Node content = sp.getContent();
if (content != null) {
Node found = findNodeByIdDeep(content, id);
if (found != null) return found;
}
} else if (root instanceof javafx.scene.control.TabPane tabPane) {
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
if (tab.getContent() != null) {
Node found = findNodeByIdDeep(tab.getContent(), id);
if (found != null) return found;
}
}
} else if (root instanceof javafx.scene.Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
Node found = findNodeByIdDeep(child, id);
if (found != null) return found;
}
}
return null;
}
private static Node findNodeDeep(Node root, Class<?> nodeType) {
if (nodeType.isInstance(root)) {
return root;
}
if (root instanceof javafx.scene.control.ScrollPane sp) {
Node content = sp.getContent();
if (content != null) {
Node found = findNodeDeep(content, nodeType);
if (found != null) return found;
}
} else if (root instanceof javafx.scene.control.TabPane tabPane) {
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
if (tab.getContent() != null) {
Node found = findNodeDeep(tab.getContent(), nodeType);
if (found != null) return found;
}
}
} else if (root instanceof javafx.scene.Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
Node found = findNodeDeep(child, nodeType);
if (found != null) return found;
}
}
return null;
}
// =========================================================================
// Threading helper
// =========================================================================
private static void runOnFx(ThrowingRunnable task) throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try {
task.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX task must complete within timeout");
if (error.get() != null) {
Throwable t = error.get();
if (t instanceof Exception e) {
throw e;
}
throw new AssertionError("Unexpected error on FX thread", t);
}
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
}
@@ -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());
}
}

Some files were not shown because too many files have changed in this diff Show More