92 Commits

Author SHA1 Message Date
marcus 20722d4365 fix: Modell-Preise Tab Buttons nach Start aktivieren (#74) 2026-05-11 06:58:09 +02:00
marcus cb3fa143fb docs: AP-A Zusammenfassung aller implementierten Klassen, Methoden und Dateien
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:51:31 +02:00
marcus 08ec021b5f feat: AP-A Token-Tracking Fundament - Schema, Adapter, Use Cases, GUI (#74)
Erste Stufe der V3.3-Spezifikation: Token- und Kosten-Tracking-Fundament.

Schema und Persistenz:
- Neue Flyway-Migration V2__token_tracking.sql mit sechs Token-/Preis-Snapshot-
  Spalten in processing_attempt, neuer model_price-Tabelle (Composite-Key
  provider+model_name) und Default-Preisen fuer beide Provider-Familien.
- SqliteModelPriceRepositoryAdapter mit UPSERT, transaktionalem Batch und
  invalidUpdatedAt-Mapping.
- Zentrale SqliteConnectionFactory; alle direkten DriverManager.getConnection-
  Stellen in den Repository-Adaptern (Document, Attempt, History, UnitOfWork)
  auf die Factory umgezogen, damit WAL und busy_timeout pro Connection greifen.

Application und Domain:
- Neue DTOs AiUsageMetadata, ModelPriceEntry/View/Key/ChangeSet, CostResult.
- AiInvocationSuccess um usageMetadata erweitert; AiAttemptContext um vier
  nullable Token-Felder.
- ProcessingAttempt um sechs Token-/Preis-Snapshot-Felder erweitert
  (Convenience-Konstruktor und withoutAiFields-Factory unveraendert).
- ModelPriceRepository-Port mit Schreib-/Lese-Trennung.
- DefaultManageModelPricesUseCase mit ChangeSet-Konfliktvalidierung,
  Provider-Whitelist und Clock-Stempel.
- CostCalculator (formatRow + calculateAttempt; formatTotal als Stub fuer AP-B).

KI-Adapter:
- AnthropicClaudeHttpAdapter und OpenAiHttpAdapter extrahieren Token-Daten
  aus den Response-Bodies inklusive Validierung (negativ, > 10 Mio., nicht
  numerisch -> NULL + WARN-Log).

BatchRunProcessingUseCase-Hook:
- DocumentProcessingCoordinator erhaelt optional ModelPriceRepository und ein
  Headless-Flag. Beim Bau eines KI-Versuchs wird der Snapshot-Preis fuer
  (Provider, Modell) geladen und mit den Token-Daten am ProcessingAttempt
  persistiert. Lookup-Fehler verlieren keinen Attempt.

GUI:
- Neuer Tab "Modell-Preise" (TableView mit Editierfeldern, Add-Dialog,
  Loesch-Bestaetigung, Konvertierung Nano-USD <-> $/1M Tokens).
- History-Tab um drei Spalten erweitert: Input-Tokens, Output-Tokens, Kosten.
- Summary-Banner um Token-, Kosten- und Cache-only-Zeile erweitert
  (Default-Werte; AP-B liefert spaeter die echten Aggregate).
- Konfigurations-Tab warnt beim Speichern, wenn das aktive Modell keinen
  Preis-Eintrag hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:49:50 +02:00
marcus b63dcf5efa docs: Spec V3.3 nach Code-Reads finalisiert (#74 #98 #99) 2026-05-09 08:36:32 +02:00
marcus 40e308f670 Merge branch 'main' of https://gitea.gecheckt.de/marcus/pdf-umbenenner 2026-05-09 08:23:39 +02:00
marcus c2c16a3407 Spezifikation für V3.3 hinzugefügt (Kostentracker) 2026-05-09 08:21:30 +02:00
marcus a4bfe0dc1c docs/freigabe-v3_2.md aktualisiert
Freigabedoku für v3.2 finalisiert
2026-05-08 05:14:47 +00:00
marcus e9061d1b1f Erhoehe new-code-Coverage durch gezielte Tests fuer Reliability-Fixes
Schliesst die durch die SonarQube-Reliability-Fixes (Commit 32e32a9)
neu eingefuehrten Quellzeilen testseitig ab, damit das new-coverage
Quality Gate von 80% wieder erreicht wird.

PdfPreviewPaneRenderingTest:
- erzeugt mit PDFBox echte ein- bzw. mehrseitige PDFs und ruft
  loadSource() auf, sodass loadAndRenderFirstPageOnWorker (inkl.
  AtomicReference-Setter fuer currentDocument/currentRenderer) und
  renderPageOnWorker (AtomicReference-Getter) tatsaechlich ausgefuehrt
  werden.

BootstrapRunnerGuiContextInitFailureTest:
- deckt die catch-Zweige fuer InvalidStartConfigurationException,
  DocumentPersistenceException und unspezifische RuntimeException in
  initializeApplicationRunContext ab. Damit werden die Pfade ausgefuehrt,
  in denen guiApplicationRunContext via AtomicReference.set(Optional.empty())
  zurueckgesetzt wird.

Keine Aenderungen an Produktivcode oder bestehenden Tests.
2026-05-07 17:53:09 +02:00
marcus 32e32a9b27 Fixe SonarQube Reliability-Issues S2789, S3077 und S2184
S2789 (32 Stellen): null-Checks auf Optional-Feldern entfernt bzw. durch
Objects.requireNonNullElse(field, Optional.empty()) ersetzt. Die zuvor
defensive Behandlung von null-Optionals erfolgt jetzt ueber den
Bibliotheksaufruf, sodass das Verhalten unveraendert bleibt, aber die
direkte Null-Pruefung gegen Optional entfaellt.

S3077 (5 Stellen): volatile-Felder mit Objekt-Referenzen durch
AtomicReference ersetzt (ScheduledExecutorServiceSchedulerAdapter,
BootstrapRunner.guiApplicationRunContext, PdfPreviewPane.currentDocument/
currentRenderer/currentSourceFile, SingleInstanceGuard.socket). Die
PdfPreviewPane-Felder werden auf JavaFX- bzw. Worker-Thread genutzt;
AtomicReference bietet hier konsistente atomare Publikation ohne
Verhaltensaenderung.

S2184 (3 Stellen): Integer-Division SECONDARY_SPACING / 2 durch
SECONDARY_SPACING / 2.0 ersetzt, damit das Insets-Argument als double
ohne implizite Truncierung berechnet wird.
2026-05-07 17:11:29 +02:00
marcus 11eac074ef Fixe SonarQube-Issues S2789 und S125
- SchedulerStatus: null-Check auf Optional<sessionTotals> entfernt (S2789)
- GuiSchedulerTab: auskommentierten Code-Kommentar entfernt (S125)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 16:26:18 +02:00
marcus eaf9b29003 Freigabedoku für V3.2 2026-05-07 16:09:26 +02:00
marcus 4a40dee5cd Bugfix: Preferences-Knoten fuer lastConfigPath versionsunabhaengig 2026-05-07 14:57:20 +02:00
marcus 368cb81b56 Feature: Scheduler-Tick-Zaehlung korrigieren und Sitzungstotale einfuehren
Der Scheduler-Tab meldete nach erfolgreicher Verarbeitung faelschlich
"keine neuen Dokumente". Ursache war ein hartkodiertes RunSummary.noOp()
im BatchRunTrigger der Bootstrap; der echte Lauf-Summary wurde nie
gelesen.

- Bootstrap: BatchRunProgressObserver erfasst RunSummary aus onRunEnded
  und uebersetzt ihn in den ausgehenden RunSummary fuer das Tick-Ergebnis
- Neuer Wert-Typ SchedulerSessionTotals (success/failed) plus
  Optional-Feld in SchedulerStatus
- DefaultSchedulerControlUseCase setzt die Totale beim start() auf null
  zurueck, summiert pro Started-Tick auf, friert sie beim stop() ein
- GuiSchedulerTab zeigt pro Tick "X verarbeitet, Y Fehler" oder
  "keine neuen Dokumente" sowie ein zusaetzliches Label
  "Seit Scheduler-Start: X verarbeitet, Y Fehler"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:51:36 +02:00
marcus ac5b74917f Bugfix: GUI-Methoden nutzen ApplicationRunContext statt erneuten Reload
Die GUI-Methoden fuer Historie und manuelle Datei-Aktionen haben Konfig
und Schema bei jedem Aufruf neu geladen. Bei aktivem Scheduler-Lock
schlug der Properties-Lesezugriff in loadAndValidateConfiguration mit
einer IOException fehl.

Zwei neue Helper bevorzugen den bereits stehenden ApplicationRunContext
und fallen nur ohne Kontext auf das alte Migrate-Load-Validate-
Schema-Init-Schema zurueck:
- resolveJdbcUrlForGui (nur JDBC-URL benoetigt)
- resolveStartConfigurationForGui (volle StartConfiguration benoetigt)

Refactoring betrifft:
- resolveHistoricalDocumentContextForGui
- loadHistoryOverviewForGui
- loadHistoryDetailsForGui
- resetHistoryDocumentStatusForGui
- deleteDocumentHistoryForGui
- performGuiManualFileRename
- performGuiManualFileCopy

Der kurzlebige Helper migrateConfigurationIfNeededForGui wurde durch
die beiden neuen Helper ueberfluessig und entfernt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:14:49 +02:00
marcus ef985fb6af Bugfix: alle GUI-Pfade ueberspringen Migration bei stehendem Run-Kontext
Konsequente Anwendung des Fix-Musters auf alle GUI-Methoden, die nach
dem Aufbau des ApplicationRunContext eine erneute, redundante
Legacy-Migration ausgeloest haetten. Bei aktivem Scheduler-Lock ist der
Lese-/Schreibzugriff auf die Konfigurationsdatei sonst blockiert.

Neue Helper-Methode migrateConfigurationIfNeededForGui kapselt den Check
auf den Run-Kontext und ersetzt den unbedingten Aufruf in:
- performGuiManualFileRename
- performGuiManualFileCopy
- resolveHistoricalDocumentContextForGui
- loadHistoryOverviewForGui (vorhandene inline-Variante zentralisiert)
- loadHistoryDetailsForGui
- resetHistoryDocumentStatusForGui
- deleteDocumentHistoryForGui

Die uebrigen Aufrufstellen bleiben unveraendert: der headless-Pfad,
initializeApplicationRunContext (die einzige zustaendige Stelle), die
GUI-Launch-Methoden mit bestehendem Early-Return bei vorhandenem Kontext
sowie die Stellen, die vor Aufbau des Kontexts laufen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:58:51 +02:00
marcus fdfc36afb7 Bugfix: Historienuebersicht kollidiert nicht mehr mit Scheduler-Config-Lock
loadHistoryOverviewForGui rief migrateConfigurationIfNeeded unbedingt
auf. Bei aktivem Scheduler haelt der Use Case den OS-Lock auf der
Konfigurationsdatei; der Migrations-Lesezugriff lief dadurch in eine
IOException.

Die Migration ist nur einmal noetig; sie wurde bereits beim Aufbau des
ApplicationRunContext durchgefuehrt. Der erneute Aufruf wird daher
uebersprungen, sobald der Kontext steht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:51:43 +02:00
marcus 8b963adb4f Bugfix: Scheduler-Close-Guard liest Use Case dynamisch
installSchedulerCloseGuard hat den Scheduler-Use-Case bisher nur einmalig
aus dem unveraenderlichen GuiStartupContext gelesen. Bei normalem
GUI-Start ohne --config war dieser Optional leer; der nach dem Auto-Load
verdrahtete Use Case wurde nicht erfasst und der Close-Guard griff nie.

Der Close-Handler wird jetzt unabhaengig vom Startup-Context installiert
und liest den Aktiv-Status zur Laufzeit ueber den Workspace, der den im
GuiSchedulerTab live verdrahteten Use Case kennt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:27:51 +02:00
marcus 1ea6465584 Bugfix: Stop-Button im Scheduler-Tab wird wieder aktiv
Die zentrale Status-Refresh-Timeline las den Scheduler-Use-Case aus dem
unveraenderlichen GuiStartupContext. Beim regulaeren GUI-Start ohne
--config ist dieser Optional leer; der via Auto-Load nachtraeglich
verdrahtete Use Case wurde dadurch nie sichtbar, updateStatus wurde nie
aufgerufen und der Stop-Button blieb dauerhaft deaktiviert.

Die Timeline liest den Status jetzt ueber den Workspace, der den live im
GuiSchedulerTab verdrahteten Use Case kennt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:21:41 +02:00
marcus 13141f9638 Scheduler: Autostart-Feature entfernen
Der Scheduler startet niemals automatisch beim Programmstart. Der Nutzer
startet ihn ausschliesslich bewusst ueber den Start-Button im
Scheduler-Tab. scheduler.enabled wird nicht mehr gelesen oder geschrieben;
das Property ist obsolet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:57:54 +02:00
marcus 719cc50d16 Bugfix V3.2: RunLockPort-JavaDoc korrigiert und Backup-Fehler bei aktivem Scheduler behoben
BUG 1: RunLockPort-JavaDoc dokumentierte den Scheduler-Tick faelschlicherweise als
nicht-blockierenden Pfad mit tryAcquire(). Da execute() intern acquire() aufruft,
wuerde tryAcquire() vor execute() einen Double-Lock erzeugen. JavaDoc korrigiert:
Scheduler-Tick nutzt denselben blockierenden acquire()-Pfad wie der manuelle Lauf.

BUG 2: GuiConfigurationPropertiesWriter.copyFile() faengt jetzt AccessDeniedException
separat ab und liefert den klaren Hinweis "Konfiguration kann nicht gespeichert
werden - Scheduler laeuft." statt einer generischen Fehlermeldung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:14:43 +02:00
marcus 4bc70dae75 GUI: ApplicationRunContext beim Datei-Öffnen proaktiv aufbauen
Bisher wurde der ApplicationRunContext nur beim --config-Startpfad
erzeugt. Der auto-load-Pfad (letzte Konfiguration aus Preferences)
baute keinen Kontext auf, was Scheduler und Batch-Vorinitialisierung
blockierte.

Neu: GuiApplicationContextInitializer-Callback, den Bootstrap für
jeden GUI-Startpfad bereitstellt. openConfigurationFile() ruft ihn
im Hintergrund-Thread auf; das Scheduler-Ergebnis wird via
Platform.runLater() an GuiSchedulerTab.onSchedulerAvailable()
übergeben.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 07:11:27 +02:00
marcus b7f9184344 SonarQube: fix alle BLOCKER- und CRITICAL-Issues (S3252, S2479, S1186, S1192, S2699, S5783, S3776)
- S3252: GuiStatusRefreshTimeline nutzt Animation.INDEFINITE statt Timeline.INDEFINITE
- S2479: Narrow-No-Break-Space (U+202F) in GuiTooltipTexts durch normales Leerzeichen ersetzt
- S1186: 134 leere Stub-Methoden in 18 Test- und Produktionsdateien kommentiert
- S1192: ~49 duplizierte String-Literale in ~25 Klassen als Konstanten extrahiert
- S2699: fehlende Assertions in SqliteSchemaInitializationAdapterTest und FilesystemTargetFolderAdapterTest ergaenzt
- S5783: Lambda-geprufte Ausnahme in SqliteSchemaInitializationAdapterTest in private Hilfsmethode extrahiert
- S3776: kognitive Komplexitaet in 8 Methoden durch Methodenextraktion auf unter 15 gesenkt
  (EarlyLogDirectoryInitializer, CliArgumentParser, GuiConfigurationEditorWorkspace,
   GuiHistoryTab x2, GuiBatchRunTab x2, DefaultManualFileCopyUseCase)
- Kompilierungsfehler behoben: private-Modifier in CorrectionOutcome-Interface entfernt,
  selbstreferenzielle Konstante in ModelCatalogResult korrigiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:27:59 +02:00
marcus 14da7ee789 Dokumentation V3.2: Scheduler-Ausnahme, Betriebsdoku und GUI-Bedienanleitung
CLAUDE.md:
- Unverrückbare Technikvorgaben: 'keine Dauerlauf-Anwendung' und 'kein interner
  Scheduler' mit Ausnahme-Hinweis auf GUI-Scheduler annotiert
- Modulstruktur: pdf-umbenenner-adapter-in-scheduler ergänzt
- Neuer Abschnitt 'Scheduler-Ausnahme (ab V3.2)' mit allen abweichenden Regeln
- Aktiver Implementierungsstand: V3.2 als abgeschlossen dokumentiert
- Konfigurationsparameter: scheduler.enabled und scheduler.interval.seconds ergänzt
- Nicht-Ziele: 'keine interne Scheduler-Logik' mit GUI-Scheduler-Ausnahme annotiert

docs/betrieb.md:
- 'Umfang der GUI': von drei auf fünf Tabs aktualisiert (Scheduler + Verlauf ergänzt)
- Neuer Abschnitt 'Automatischer Scheduler' mit Parametern, Autostart, Sperr-
  verhalten und Schließ-Verhalten
- Optionale Parameter: scheduler.enabled und scheduler.interval.seconds ergänzt
- Systemgrenzen: 'Kein interner Scheduler' auf headless-Kontext eingeschränkt

docs/gui-bedienanleitung.md:
- Abschnitt 1: fünf statt vier Tabs; Tab 3 Scheduler neu; Verlauf zu Tab 4,
  Prompt zu Tab 5; alle Abschnitt-Querverweise aktualisiert
- Abschnitte 14-20 zu 15-21 umnummeriert
- Neuer Abschnitt 14 'Tab Scheduler' mit Start/Stop, Statusanzeige, Countdown,
  letztem Lauf, Autostart-Fehler, Sperrbegründungen und Schließ-Dialog-Hinweis

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:04:23 +02:00
marcus 7aed0f3730 SonarQube: JaCoCo-Pfad-Mapping durch per-Modul-Reports lösen
Problem: Der Aggregate-Report enthält Klassen aller Module. SonarQube
analysiert jedes Modul isoliert und findet für Klassen anderer Module
keine Quellen → "File not found" für alle Einträge. Das Coverage-Modul
(kein Java-Code) lehnt beim Import alle Einträge ab.

Lösung:
- jacoco:report-Goal (verify-Phase) im Root-POM ergänzt → jedes Modul
  erzeugt target/site/jacoco/jacoco.xml nur für seine eigenen Klassen
- sonar.coverage.jacoco.xmlReportPaths auf relativen Pfad target/site/jacoco/jacoco.xml
  umgestellt → SonarQube löst pro Modul auf, liest ausschließlich dessen
  eigene Klassen, keine Cross-Modul-Kollisionen mehr
- sonar.skip=true in pdf-umbenenner-coverage und pdf-umbenenner-packaging
  gesetzt → Aggregator-/Packaging-Module ohne Java-Quellen werden von
  SonarQube nicht mehr analysiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:45:11 +02:00
marcus 62cab1ccc4 Schritte 11-13: Config-Tab-Sperre, Batch-Button-Sperre, Scheduler-Close-Guard
Schritt 11: updateLockState() implementiert in GuiConfigurationEditorWorkspace
- schedulerLockActive-Feld eingeführt
- applyBatchRunLockState() delegiert an neues applyConfigTabLockState()
- applyConfigTabLockState() vereint Batch-Run- und Scheduler-Sperre:
  Banner, sectionsBox, Neu/Öffnen/Speichern/Speichern-unter werden gesperrt
  wenn Scheduler aktiv oder Lauf aktiv

Schritt 12: updateSchedulerState() implementiert in GuiBatchRunTab
- schedulerActive-Feld eingeführt
- Starten-Button wird deaktiviert + Tooltip gesetzt wenn Scheduler läuft
- updateButtonStates() berücksichtigt schedulerActive damit Sperre beim
  Laufende nicht verloren geht

Schritt 13: Scheduler-Close-Guard in PdfUmbenennerGuiApplication
- installSchedulerCloseGuard() als äußerste Schicht des Close-Handlers
- Zeigt Informationsdialog und verhindert Beenden wenn Scheduler aktiv
- Bestehender Workspace-/Tray-Handler bleibt erhalten wenn Scheduler gestoppt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:28:52 +02:00
marcus 9f6c6f266b SonarQube: jacoco.xmlReportPaths auf absoluten Modulpfad umstellen
Der Glob **/jacoco-aggregate/jacoco.xml wird von SonarQube pro Modul relativ
zu dessen basedir aufgelöst und findet den Aggregate-Report im Geschwistermodul
pdf-umbenenner-coverage nicht. Maven löst ${project.basedir} hingegen vor der
Übergabe an das Sonar-Plugin zu einem absoluten Pfad auf, sodass
../pdf-umbenenner-coverage/... für alle Kind-Module korrekt zeigt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:10:00 +02:00
marcus 2af6d8d9bb SonarQube: jacoco.xmlReportPaths in Root-POM auslagern
sonar.coverage.jacoco.xmlReportPaths wird jetzt als Property im Root-POM
gepflegt statt als -D-Parameter im mvn sonar:sonar-Befehl des Jenkinsfile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:06:50 +02:00
marcus fa4f327a3f Schritt 10: GuiSchedulerTab implementieren und in Workspace verdrahten
- SchedulerControlUseCase um getIntervalSeconds(), saveIntervalSeconds(), disableAutostart() erweitert
- DefaultSchedulerControlUseCase implementiert diese drei neuen Methoden
- GuiSchedulerTab neu eingeführt: Autostart-Fehler-Banner + Scheduler-Steuerung
  (Status, Start/Stopp, Countdown, letzter Lauf, Fehleranzeige, Intervall-Feld)
- GuiConfigurationEditorWorkspace: schedulerTab als 3. Tab (nach Verarbeitungslauf)
  eingehängt; onSchedulerStatusRefresh delegiert jetzt auch an schedulerTab.updateStatus()
- GuiAdapterSmokeTest: Tab-Anzahl und -Reihenfolge auf 5 Tabs aktualisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:05:24 +02:00
marcus 0cec9347c1 Aufruf von SonarQube bzgl. Testabdeckung korrigiert 2026-05-06 15:45:10 +02:00
marcus e509160621 SonarQube Aufruf angepasst um Testergebnisse / -abdeckung 2026-05-06 15:24:44 +02:00
marcus 8c5d129439 Führe zentrale GuiStatusRefreshTimeline ein (1 Hz, alle Tabs)
PdfUmbenennerGuiApplication startet nach dem Anzeigen des Hauptfensters
eine GuiStatusRefreshTimeline, die im Sekundentakt refreshAllTabStates()
aufruft. Die Methode liest schedulerControlUseCase.getStatus() (falls
present) und delegiert an workspace.onSchedulerStatusRefresh(status).

GuiConfigurationEditorWorkspace.onSchedulerStatusRefresh() leitet den
Status an batchRunTab.updateSchedulerState() und updateLockState() weiter.
Beide Methoden sind vorerst leere Stubs; die Implementierung folgt in
späteren Schritten. Ebenso bleibt der zukünftige GuiSchedulerTab-Aufruf
ausgespart bis Schritt 10.

GuiStatusRefreshTimeline ist eine eigenständige Klasse im gui-Paket,
konsistent mit den bestehenden Coordinator-Klassen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:22:39 +02:00
marcus 74e825d1f4 Erwirb Config-Lock vor manuellem Verarbeitungslauf in der GUI
GuiBatchRunCoordinator erwirbt vor jedem Verarbeitungslauf (regulär und
Mini-Lauf) einen exklusiven OS-Lock auf die Konfigurationsdatei via
ConfigurationFileLockPort. Bei ConfigurationFileLockException wird ein
deutscher Fehlerdialog angezeigt und der Lauf abgebrochen. In finally
wird der Lock immer freigegeben.

GuiStartupContext erhält das 27. Feld configurationFileLockPort;
BootstrapRunner befüllt es mit einem FileChannelConfigurationAccessAdapter
wenn eine Konfigurationsdatei geladen wurde. GuiBatchRunTab und
GuiConfigurationEditorWorkspace reichen den Port durch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:11:55 +02:00
marcus ce87b0bbec SonarQube-Aufruf angepasst. 2026-05-06 15:01:59 +02:00
marcus d66364e254 Bootstrap-Wiring: Scheduler in GUI-Startkontext verdrahten
- pdf-umbenenner-bootstrap/pom.xml: Abhängigkeit auf adapter-in-scheduler hinzugefügt
- GuiStartupContext: neues Feld schedulerControlUseCase (Optional<SchedulerControlUseCase>)
  als 26. Record-Komponente; 25-Parameter-Backward-Compat-Konstruktor sichert Abwärtskompatibilität
- DefaultSchedulerControlUseCase: öffentliche Methode markAutostartFailed() ergänzt
- BootstrapRunner: guiSchedulerUseCase-Feld, tryInitializeScheduler(), stopGuiSchedulerIfActive()
  sowie BatchRunTrigger-Lambda; Autostart gemäß scheduler.enabled-Konfiguration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:42:35 +02:00
marcus 434c882d7d SonarQube zur Jenkins-Pipeline hinzugefügt 2026-05-06 14:15:47 +02:00
marcus 8bd25d06c0 Implementiere DefaultSchedulerControlUseCase für Scheduler-Orchestrierung
Implementiert SchedulerControlUseCase als zentralen Orchestrator:
- start()-Sequenz mit STARTING → RUNNING_IDLE und vollständigem Rollback
- stop()-Sequenz mit CAS-gesichertem STOPPING_BATCH_ACTIVE für laufende Batches
- executeWrappedTick() (package-private) setzt RUNNING_BATCH_ACTIVE vor dem Trigger
  und leitet Folgezustand aus BatchRunTriggerResult-Variante ab
- AtomicReference<SchedulerStatus> für threadsichere Zustandsverwaltung
- Intervall wird beim Start aus SchedulerSettingsPort geladen, Minimum 30 s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:10:10 +02:00
marcus 3022a9a16f Implementiere ScheduledExecutorServiceSchedulerAdapter für SchedulerPort
Single-Thread-Executor mit scheduleWithFixedDelay (Initial Delay 0).
Thread-Name: pdf-umbenenner-scheduler, Non-Daemon, UncaughtExceptionHandler
auf ERROR. Alle Ausnahmen in onTick() werden abgefangen, damit der
Tick-Zyklus nicht still abbricht. currentTrigger und onTick() sind
package-private für direkte Testbarkeit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:24:45 +02:00
marcus aeb3323180 Implementiere FileChannelConfigurationAccessAdapter für ConfigurationFileLockPort und SchedulerSettingsPort
Der Adapter teilt intern einen FileChannel und ermöglicht so das Schreiben
von Scheduler-Einstellungen auch während eines aktiven OS-Locks. Schreibvorgänge
laufen ohne Lock über eine temporäre Datei (ATOMIC_MOVE); mit Lock direkt über
den Kanal (Truncate → Write → Force). Zeilenenden (CRLF/LF) und alle übrigen
Properties-Zeilen bleiben unverändert erhalten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:14:40 +02:00
marcus c2a7921675 Scheduler-Ports und Typen im Application-Modul anlegen (Schritt 3)
Neue Typen (port/in):
- SchedulerControlUseCase – Inbound-Port: start(), stop(), getStatus()
- SchedulerState – Enum: STOPPED, STARTING, RUNNING_IDLE, RUNNING_BATCH_ACTIVE, STOPPING_BATCH_ACTIVE
- SchedulerStatus – Immutable Record mit AtomicReference-ready Snapshot
- SchedulerStartException – Unchecked Exception für Start-Fehler

Neue Typen (port/out):
- RunLockHandle – AutoCloseable für tryAcquire() in try-with-resources
- RunSummary – Aggregierte Lauf-Ergebniszähler (success/failed/skipped)
- BatchRunTrigger – @FunctionalInterface für synchronen Lauf-Trigger
- BatchRunTriggerResult – Sealed Interface: Started, SkippedBusy, Failed
- SchedulerConfig – Betriebskonfiguration (intervalSeconds >= 30)
- SchedulerSettings – Persistierte Properties-Werte mit Defaults
- SchedulerPort – startScheduler() / stopScheduler()
- ConfigurationFileLockPort – acquireLock() / releaseLock() / isLocked()
- ConfigurationFileLockException – Unchecked bei Lock-Erwerb-Fehler
- SchedulerSettingsPort – loadSettings() / saveEnabled() / saveIntervalSeconds()
- SchedulerSettingsWriteException – Unchecked bei Schreib-Fehler

Erweiterungen:
- RunLockPort: neue Methode tryAcquire() → Optional<RunLockHandle>
- FilesystemRunLockPortAdapter: implementiert tryAcquire() atomar via
  CREATE_NEW; idempotentes Handle via AtomicBoolean

Test-Fixes:
- 9 Mock-Klassen in application- und bootstrap-Tests um tryAcquire()
  ergänzt (liefern Optional.empty(), da nur blockierender Pfad getestet)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:42:42 +02:00
marcus 93a2473c36 Neues Maven-Modul pdf-umbenenner-adapter-in-scheduler anlegen
Erzeugt das Modul-Gerüst für den autonomen Scheduler-Adapter:
- pom.xml mit Abhängigkeit auf pdf-umbenenner-application (kein Bootstrap,
  kein JavaFX, kein Shade-Plugin); flatten-maven-plugin und PIT werden mit
  bewusstem Kommentar vom Parent geerbt; JaCoCo-Schwellwerte temporär auf 0
- package-info.java für das Paket de.gecheckt.pdf.umbenenner.adapter.in.scheduler
- SchedulerPlaceholder.java als temporäre Kompilierplatzhalter-Klasse
- Modul in der Parent-pom.xml zwischen adapter-in-gui und adapter-out registriert

Die Abhängigkeitsrichtung (adapter-in-scheduler → application → domain)
verhindert den zyklischen Bezug: Bootstrap wird in einem späteren Schritt
auf dieses Modul angewiesen sein, nicht umgekehrt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:21:05 +02:00
marcus 791499169f Spezifikation für V3.2 hinzugefügt 2026-05-06 12:11:39 +02:00
marcus 407f1e0422 Bootstrap-Refactoring: Init/Run-Trennung mit ApplicationRunContext
Führt ApplicationRunContext als package-private Record ein, der beim
GUI-Start einmalig aus der validierten Konfiguration gebaut wird
(migrate → load → validate → schema-init). Das Ergebnis wird in
guiApplicationRunContext gecacht und von launchGuiBatchRun,
launchGuiMiniBatchRun und resetDocumentStatusForGui wiederverwendet,
sodass die Init-Sequenz nicht bei jedem Lauf wiederholt wird.

GuiStartupContext erhält das neue Feld applicationContextError
(Optional<String>), das einen deutschen Fehlertext trägt, wenn der
Kontext bei Startup nicht initialisiert werden konnte. Alle bisherigen
Konstruktoren und die blank()-Fabrik wurden rückwärtskompatibel
ergänzt.

Der Test-Helfer runnerWithGuiFactory wirft jetzt
ConfigurationLoadingException statt AssertionError, damit
initializeApplicationRunContext() den Fehler gracefully abfangen
und in applicationContextError speichern kann.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:07:39 +02:00
marcus ca26d181f3 Freigabe-Doku für V3.1 um Buildnummer ergänzt 2026-05-06 07:55:28 +02:00
marcus eae2472b7e Abschluss-Dokumentation V3.1: betrieb.md, Bedienanleitung, Freigabedokument
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:45:32 +02:00
marcus 735b3af09f Erlaube .db-Endung im FileChooser „Neue Datenbank anlegen"
Der Filter akzeptiert jetzt *.db und *.sqlite. Der vorgeschlagene
Dateiname übernimmt die Endung der aktuell konfigurierten DB-Datei
(neue-datenbank.db bzw. neue-datenbank.sqlite).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:31:09 +02:00
marcus 3876e647b2 Lege neue leere SQLite-Datenbank atomar via Use-Case und GUI an
Neuer Menüpunkt „Datenbank → Neue Datenbank anlegen…" mit FileChooser,
normalisierter Pfadprüfung gegen die aktive DB, gesammelter Überschreib-
Bestätigung, DB-Busy-Sperre auf Verlauf-Tab, Flyway-Migration auf den
neuesten Stand gegen eine Temp-Datei, Verbindungstest, atomarem Move
(ATOMIC_MOVE + REPLACE_EXISTING) und Umstellen der aktiven DB-Referenz
über einen neuen ActiveDatabaseContextPort. Konfig-Tab wechselt nach
Wechsel automatisch in den Dirty-State; Hinweismeldung mit Speichern-
Aufforderung wird im zentralen Meldungsbereich angezeigt.

Architektur entspricht Fall B aus der Spezifikation: Bootstrap hält den
Override prozessweit und verwendet ihn in resolveActiveJdbcUrl statt
des Werts aus der .properties-Datei. Bei Fehlern wird die Temp-Datei
zuverlässig entfernt; die aktive DB bleibt unverändert in Betrieb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:52:54 +02:00
marcus 90d95b9ff8 Zentriere PDF-Vorschau via viewStack-Mindestgröße statt Timing-Hacks
Korrekte Ursachenanalyse: Im Zoom-Modus schrumpft der viewStack auf
Inhalts-Größe (ImageView). Ist der Inhalt kleiner als der Viewport,
positioniert ScrollPane den viewStack links/oben – setHvalue(0.5) ist
wirkungslos, weil nichts zu scrollen ist. Alle bisherigen runLater/
ChangeListener/AnimationTimer-Ansätze haben am falschen Hebel gedreht.

Korrekter Fix: viewportBoundsProperty-Listener im Konstruktor zwingt
viewStack auf mindestens Viewport-Größe. Pos.CENTER zentriert dann
die ImageView automatisch, wenn sie kleiner ist; bei größerem Inhalt
bleibt die Mindestgröße wirkungslos und der ScrollPane scrollt normal.

Ersatzlos entfernt: AnimationTimer-Block in applyZoom (wasInFitMode-
Zweig), Folge-Schritt-runLater (else-Zweig), setHvalue(0.5)/setVvalue(0.5)
in resetToFitView. Bindings in resetToFitView bleiben unverändert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:15:44 +02:00
marcus 661894f1ec Zentriere ersten Zoom-Schritt mittels AnimationTimer-Single-Shot
ChangeListener auf hvalueProperty feuert nicht zuverlässig: wenn hvalue
im Fit-Modus bereits 0.5 (oder identisch zum Reset-Wert) ist, gibt es
keine Wertänderung beim setFitToWidth(false), und der Listener läuft
nie an – der spätere JavaFX-eigene Reset auf 0.0 bleibt unkontrolliert.

AnimationTimer.handle() läuft einmal pro JavaFX-Frame, nach allen
Layout-, CSS- und Pulse-Passes des aktuellen Frames. Das ist der einzige
in JavaFX zuverlässige Mechanismus, um nach allem zu feuern, was JavaFX
in diesem Frame noch erledigt. stop() im ersten handle() macht den Timer
zum Single-Shot.

Folge-Zoom-Schritte (wasInFitMode == false) bleiben unverändert mit
einfachem Platform.runLater.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:03:29 +02:00
marcus 0651fcb6eb Fange JavaFX-Reset von hvalue mit ChangeListener ab statt per Timing
Beim Verlassen des Fit-Modus resettet JavaFX hvalue mehrfach auf 0.0,
auch nach unserem Platform.runLater-Aufruf. Verschachtelte runLater
können diesen Reset nicht zuverlässig überholen.

Lösung: Single-Shot-ChangeListener auf hvalueProperty. Er feuert beim
Reset, entfernt sich selbst und postet erst dann setHvalue(0.5)/
setVvalue(0.5) – garantiert nach dem Reset, ohne Timing-Annahmen.

Folge-Zoom-Schritte (wasInFitMode == false) bleiben unverändert mit
einfachem runLater.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 15:51:35 +02:00
marcus b62db18f0c Verschachtele runLater in applyZoom für zentriertes Verhalten beim ersten Zoom
Beim Verlassen des Fit-Modus (setFitToWidth(false)) löst JavaFX einen
H/V-Reset auf 0.0 aus, der innerhalb desselben Pulses passiert wie unser
setHvalue(0.5)-Aufruf im einfachen Platform.runLater. Resultat: Der Reset
überschreibt unseren Wert, die PDF springt links/oben bündig.

Lösung analog zu resetToFitView: doppelt verschachteltes runLater. Das
erste runLater stößt den Layout-Pass nach setFitToWidth(false) an; das
zweite feuert im darauffolgenden Pulse, wenn alle Layout-Folgen
abgeschlossen sind und setHvalue(0.5)/setVvalue(0.5) zuverlässig wirken.

Folge-Zoom-Schritte (wasInFitMode == false) bleiben mit einfachem
runLater und bewahren die aktuelle Scroll-Position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 15:39:27 +02:00
marcus 3fb511601c Korrigiere Reihenfolge in resetToFitView für zuverlässige Zentrierung
Zwei zusammenwirkende Ursachen für die linksbündige Anzeige nach Zoom-Reset:

1. Die Property-Bindungen wurden vor setFitToWidth(true) gesetzt. Zu diesem
   Zeitpunkt sizet der viewStack noch nach der zoom-großen ImageView, sodass
   die Bindungen die imageView an die Zoom-Breite gekoppelt haben statt an
   die Viewport-Breite.

2. Verbleibende H/V-Werte aus Pan-/Zoom-Modus (insbesondere hvalue=0.0 nach
   Pan zum linken Rand) wurden nicht zurückgesetzt. Bei minimalsten
   Rounding-/Border-Differenzen wirkt hvalue auch im fit-aktiven Modus und
   richtet den Content links bündig aus.

Fix: setFitToWidth/Height(true) sofort; Bindings und setHvalue(0.5)/
setVvalue(0.5) im Platform.runLater nach abgeschlossenem Layout-Pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 15:26:31 +02:00
marcus a8d8a4a3c1 Kalibriere zoomLevel beim Verlassen des Fit-Modus auf visuellen Skalierungsfaktor
Beim ersten Zoom-Schritt sprang die ImageView abrupt von der visuell
sichtbaren Breite (durch fitHeight aspekt-erhaltend verkleinert) auf
naturalViewportWidth × zoomLevel, weil zoomLevel mit dem Wert 1.0 nicht
zur tatsächlich angezeigten Skalierung passte und gleichzeitig setFitHeight(0)
die Höhenrestriktion entfernte.

applyZoom() initialisiert nun beim Verlassen des Fit-Modus zoomLevel
auf currentVisualWidth / naturalImageWidth (= aktueller visueller
Skalierungsfaktor) und setzt naturalViewportWidth auf die natürliche
Bildbreite. Damit entspricht zoomLevel = 1.0 der pixel-genauen
Originaldarstellung. Der vom Caller intendierte Delta-Schritt wird vor
der Kalibrierung gesichert und nach der Kalibrierung auf den neuen
zoomLevel re-appliziert, damit applyZoom(zoomLevel + 0.10) nicht
unverändert auf den kalibrierten Wert feuert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 15:16:25 +02:00
marcus 3ef8fd0dc3 Imports aufgeräumt 2026-05-05 14:56:16 +02:00
marcus 265b807263 Entferne wirkungslosen H/V-Workaround in resetToFitView
Bei aktivem fitToWidth/fitToHeight hat der ScrollPane keinen scrollbaren
Bereich – setHvalue(0.5)/setVvalue(0.5) sind in diesem Zustand wirkungslos.
Die Wiederherstellung der Property-Bindungen fitWidth/fitHeight an viewStack
versetzt den ImageView in exakt denselben Zustand wie nach der initialen
Konstruktor-Initialisierung. Der StackPane zentriert dann automatisch
über die bereits gesetzte Pos.CENTER-Ausrichtung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:51:08 +02:00
marcus b4f2bf60c6 Verschachtele Platform.runLater in resetToFitView für zuverlässige Zentrierung
Ein einzelner Platform.runLater-Aufruf kann feuern, bevor JavaFX das Layout
nach setFitToWidth(true) vollständig abgeschlossen hat. Durch Verschachtelung
eines zweiten runLater werden setHvalue(0.5) und setVvalue(0.5) erst nach dem
nächsten vollständigen Layout-Pass gesetzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:39:32 +02:00
marcus 15ff034a2b Behebe Zoom-Sprung und Zentrierung nach Rauszoomen
Bug 1: deltaY vor Akkumulation auf einen Notch-Wert begrenzen.
Plattformspezifische Scroll-Multiplikatoren (Windows-Mausgeschwindigkeit,
hohe DPI-Mäuse) können Werte wie 120 statt 40 liefern. Ohne Normierung
akkumuliert sich ein Überlaufwert, der Folge-Events sofort auslöst.

Bug 2: resetToFitView() setzt nach setFitToWidth(true) explizit
scrollPane.setHvalue(0.5) und setVvalue(0.5) (nach layout()-Aufruf),
damit vorherige Pan-Scroll-Werte die Zentrierung nicht nachwirken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:31:29 +02:00
marcus 9c27e4df01 Implementiere PDF-Vorschau: Zoom-Verbesserungen und Grab & Pan
32c: ScrollPane.setPrefSize(0,0) und StackPane.setMinSize(0,0) verhindern,
dass der Vorschaubereich beim manuellen Zoom mitwächst.

32a: Zoom-Akkumulator nutzt if statt while – pro Mausrad-Raste wird genau
eine Zoom-Stufe (10 %) angewendet, auch bei großen deltaY-Werten.

32b: Beim ersten Zoom-Einstieg wird die Ansicht auf die Bildmitte
zentriert (H/V = 0.5). scrollPane.layout() vor der Scroll-Wert-
Restaurierung stellt sicher, dass die neuen Inhaltsgrenzen bekannt sind.

32d: Grab & Pan – im manuellen Zoom-Modus kann die Vorschau mit der Maus
verschoben werden. OPEN_HAND-Cursor signalisiert den Zoom-Modus,
CLOSED_HAND die aktive Pan-Geste.

32e: resetToFitView() setzt Pan-Zustand und Mauszeiger zurück, sodass
beim Laden einer neuen Datei der Fit-to-View-Modus vollständig
wiederhergestellt wird.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:09:44 +02:00
marcus 0412874f08 #88 + #77: Fehlerursache-Übersetzung und vollständige Tooltip-Abdeckung
Aufgabe 1 (#88): AiFailureMessageTranslator auf public gesetzt, damit der
Verlauf-Tab die technischen Fehlermeldungen in benutzerfreundliche deutsche
Texte übersetzen kann.

Aufgabe 2 (#77): Vollständige Bestandsaufnahme aller interaktiven GUI-Elemente.
13 neue Konstanten in GuiTooltipTexts ergänzt (Provider-Felder, Verarbeitungs-
limits, optionale Pfade, Vorschau-Navigation, Prompt-Buttons, Dateiname-Textfeld).
Alle fehlenden Tooltips in GuiConfigurationEditorWorkspace, GuiPromptEditorTab,
PdfPreviewPane und FileNameEditorPane gesetzt. Hartcodierte Strings in
GuiPromptEditorTab durch Konstantenreferenzen ersetzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 13:25:56 +02:00
marcus 6c2e2efe22 #86: Mehrfachauswahl im Verlauf-Tab (SelectionMode.MULTIPLE)
Strg+Klick, Shift+Klick und Strg+A (alle sichtbaren Eintraege) werden durch
JavaFX natuerlich unterstuetzt. Aktionsbuttons (Reset, Loeschen) arbeiten nun
auf allen selektierten Eintraegen. Bei Status-Reset wird ein Hinweis angezeigt,
wenn SUCCESS-Eintraege in der Auswahl enthalten sind (Partial-Success-Dialog).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:45:23 +02:00
marcus 9f222208c0 #82: Live-Filter im Verlauf-Tab mit 300-ms-Debounce
Das Suchfeld löst loadOverview() nach 300 ms Tippinaktivität automatisch aus
(PauseTransition). Enter-Taste stoppt den Timer und sucht sofort. So wird die
Tabelle live gefiltert, ohne bei jedem Tastendruck eine DB-Anfrage zu starten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:35:30 +02:00
marcus beade6ba2e #32: Mausrad-Zoom (Strg+Rad) in PDF-Vorschau ergänzt
Strg + Mausrad ändert den Zoomfaktor in 10-%-Stufen (Bereich 10–500 %).
Beim ersten Zoom verlässt die Vorschau den Fit-to-View-Modus; das ScrollPane
übernimmt dann die Scrollbarkeit. Laden einer neuen Datei setzt den Zoom
automatisch auf Fit-to-View zurück.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:31:12 +02:00
marcus 1ffd565bd7 #80: Dirty-Indikator im Tab-Titel Konfiguration ergaenzen
refreshHeader() setzt Tab-Titel auf '* Konfiguration' wenn editorState dirty ist.
Dialog bei Neu/Oeffnen/Schliessen war bereits vorhanden (unsavedChangesGuard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:21:32 +02:00
marcus e8732d749a #77: Fehlende Tooltips ergaenzt (Bestandsaufnahme + vollstaendige Umsetzung)
GuiTooltipTexts: neue Konstanten fuer Batchrun-Buttons, Verlauf-Spalten,
KI-Begruendung, Fehlerbereich, Modell-Neu-Laden, Browser-Button, Prompt-Textarea.
Spaltenkopf-Tooltips via Label-als-Graphic-Pattern in GuiHistoryTab und
GuiBatchRunTab; Buttons in allen Tabs beruecksichtigt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:15:44 +02:00
marcus 5a97979585 #83: KI-Begruendung bei leerem Reasoning als promptText anzeigen
showReasoning() nutzt setText("") + setPromptText() statt sichtbarem Fuelltext,
damit leere Begründung klar als erwarteter Zustand erkennbar ist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:05:26 +02:00
marcus 0fd0349a78 #81: Enum-Rohnamen durch deutsche Anzeigetexte ersetzen
displayTextFor(ProcessingStatus) in ProcessingStatusPresentation ergaenzt.
Status-ComboBox als ComboBox<ProcessingStatus> mit StringConverter umgestellt;
Versuche-Tabelle und Detail-Statuslabel zeigen nun Anzeigetext statt Enum-Namen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:01:44 +02:00
marcus 5129d3c9f6 #84: Aktionsbuttons im Verlauf-Tab nach Laufende reaktivieren
notifyRunEnded() in GuiHistoryTab ergänzt; GuiConfigurationEditorWorkspace
verdrahtet batchRunTab.runningProperty() und ruft notifyRunEnded() via
Platform.runLater() auf, sobald der Lauf endet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:54:48 +02:00
marcus cec3b4fb84 #88: Fehlerursache bei FAILED_FINAL im Verlauf-Tab anzeigen (Fall A)
Schema-Analyse ergab Fall A: failure_message ist bereits in V1 vorhanden
und wird persistiert. Keine Flyway-Migration notwendig.

- GuiHistoryTab: TextArea 'Fehlerursache' ergaenzt; zeigt failure_message
  des letzten Fehler-Attempts bei FAILED_FINAL, FAILED_RETRYABLE,
  SKIPPED_FINAL_FAILURE; promptText-Platzhalter bei NULL/leer
- SqliteProcessingAttemptRepositoryAdapter: 1000-Zeichen-Limit fuer
  failure_message vor Persistierung erzwungen (mit Kuerzungsmarkierung)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:46:37 +02:00
marcus 38b2d8c3b2 #91: Lock-File relativer Pfad – zweistufige Fallback-Strategie
Absoluter konfigurierter Pfad wird direkt verwendet (kein Fallback).
Relativer oder fehlender Pfad wird zweistufig aufgeloest:
1. Relativ zum JAR-Verzeichnis (CodeSource.getLocation())
2. Fallback auf user.home
Der final verwendete Pfad wird auf INFO-Ebene geloggt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:38:03 +02:00
marcus 9c49fc61c0 Spezifikation für V3.1 angelegt 2026-05-05 10:59:57 +02:00
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
218 changed files with 25439 additions and 1768 deletions
+33 -3
View File
@@ -56,8 +56,8 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
- `--config <pfad>` steht für GUI und headless zur Verfügung
- kein Webserver
- kein Applikationsserver
- keine Dauerlauf-Anwendung
- kein interner Scheduler
- keine Dauerlauf-Anwendung (Ausnahme: GUI-Modus mit aktivem Scheduler, s. Scheduler-Ausnahme)
- kein interner Scheduler (Ausnahme: optionaler GUI-Scheduler ab V3.2, s. Scheduler-Ausnahme)
- das Shade-JAR bleibt das primäre Distributionsartefakt
- zusätzlicher nativer Windows-Installer (MSI) ab V3.0 via Maven-Profil `release` (jpackage, WiX Toolset 3.x im PATH erforderlich); der Normalbuild `mvn clean verify` bleibt vom Profil unberührt und benötigt kein WiX
- Log4j2 für Logging
@@ -77,9 +77,28 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
- `pdf-umbenenner-application`
- `pdf-umbenenner-adapter-in-cli`
- `pdf-umbenenner-adapter-in-gui`
- `pdf-umbenenner-adapter-in-scheduler`
- `pdf-umbenenner-adapter-out`
- `pdf-umbenenner-bootstrap`
## Scheduler-Ausnahme (ab V3.2)
Ab V3.2 enthält der GUI-Modus einen optionalen internen Scheduler, der periodisch
automatische Verarbeitungsläufe anstößt. Die folgenden Regeln gelten abweichend von
den allgemeinen Leitplanken:
- Der Scheduler ist **ausschließlich im GUI-Modus** verfügbar. Im headless Betrieb werden
`scheduler.enabled` und `scheduler.interval.seconds` vollständig ignoriert.
- Das Modul `pdf-umbenenner-adapter-in-scheduler` erfüllt eine gemischte Rolle als
technischer Treiber und Adapter. Dies ist ein bewusster Architekturkompromiss, kein
Architekturbruch.
- `pdf-umbenenner-adapter-in-scheduler` enthält **kein JavaFX**.
- **Kein WatchService:** Der Scheduler löst reguläre Verarbeitungsläufe periodisch aus;
er nutzt keinen Dateisystem-Event-Mechanismus.
- Das bestehende Datenbankschema bleibt in V3.2 unverändert; keine
Scheduler-spezifische Schemaerweiterung.
- Token- und Kostentracking sind nicht Bestandteil von V3.2.
## Architekturregeln
- Strikte **hexagonale Architektur / Ports and Adapters**
- Abhängigkeiten zeigen immer **nach innen**
@@ -151,6 +170,8 @@ Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technis
Verhaltensänderungen seit V2.9: Die GUI startet maximiert, und die zuletzt geladene Konfigurationsdatei wird beim Start automatisch wieder geladen; existiert sie nicht mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
**V3.2 ist abgeschlossen.** Der GUI-Modus wurde um einen optionalen automatischen Scheduler erweitert (neuer Tab „Scheduler"). Der Scheduler startet periodisch Verarbeitungsläufe; Intervall und Autostart sind konfigurierbar. Während der Scheduler aktiv ist, sind der Konfigurations-Tab und das manuelle Starten von Läufen gesperrt. Im headless Betrieb werden Scheduler-Parameter vollständig ignoriert.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert.
## Statussemantik
@@ -240,6 +261,13 @@ Bestehende Kommentare mit solchen Bezeichnern, die durch eigene Änderungen ber
- Keine stillen Änderungen am bestehenden headless Batch-Betrieb
- GUI-Code darf den headless Pfad nicht unnötig früh initialisieren
## Commit und Push nach jeder Implementierung
Nach jeder Implementierung oder Dateiänderung wird ein Commit auf `main` erstellt und gepusht:
1. Geänderte Dateien stagen und committen
2. `git push origin main` ausführen
3. Schlägt der Push mit einem AUTH-Fehler fehl: 1 Sekunde warten, dann genau **einen** weiteren Versuch unternehmen
4. Schlägt auch der zweite Versuch fehl: Fehler benennen, keinen weiteren automatischen Retry
## Definition of Done pro Arbeitspaket
Ein Arbeitspaket ist erst fertig, wenn:
- der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist
@@ -294,6 +322,8 @@ Verbindlich zweckmäßige Parameter:
- `log.ai.sensitive` sensible KI-Logausgabe freischalten (Boolean, Default: `false`)
- `runtime.lock.file` Lock-Datei (optional)
- `log.directory` Log-Verzeichnis (optional)
- `scheduler.enabled` Scheduler im GUI-Modus aktivieren (Boolean, Default: `false`; wird im headless Betrieb vollständig ignoriert)
- `scheduler.interval.seconds` Intervall zwischen automatischen Läufen in Sekunden (Integer >= 30, Pflicht wenn `scheduler.enabled=true`; wird im headless Betrieb vollständig ignoriert)
Pro Provider-Familie existiert ein eigener Parameter-Namensraum:
@@ -332,7 +362,7 @@ Verbindlicher Ablauf:
- keine OCR innerhalb der Java-Anwendung
- keine DMS-Funktionalität
- kein menschlicher Review-Workflow in der Anwendung
- keine interne Scheduler-Logik
- keine interne Scheduler-Logik außerhalb des optionalen GUI-Schedulers (s. Scheduler-Ausnahme)
- keine Architekturbrüche
- keine neuen Bibliotheken oder Frameworks ohne klare Notwendigkeit und Begründung
- **keine** automatische Fallback-Umschaltung zwischen KI-Providern
Vendored
+110 -67
View File
@@ -8,76 +8,114 @@
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: 'Hauptversionsnummer (manuell pflegen)'
)
string(
name: 'MINOR',
defaultValue: '0',
description: 'Nebenversionsnummer (manuell pflegen)'
)
}
environment {
// Effektive Versionsteile übernommen aus Parametern oder State-Datei.
// Hinweis: Wenn MAJOR/MINOR aus einer persistierten State-Datei gelesen
// werden sollen (z. B. /builds/version.state), muss die Logik unten in
// der Stage 'Version bestimmen' entsprechend ergänzt werden.
// Im Minimalbetrieb werden die Parameter direkt übernommen.
EFFECTIVE_MAJOR = "${params.MAJOR}"
EFFECTIVE_MINOR = "${params.MINOR}"
string(name: 'MAJOR', defaultValue: '3', description: 'SemVer MAJOR (manuell)')
string(name: 'MINOR', defaultValue: '0', description: 'SemVer MINOR (manuell)')
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
// Optionaler Stub: MAJOR/MINOR aus persistierter State-Datei laden.
// Wenn das bestehende Jenkins-Setup die Versionsnummern in einer
// State-Datei unter /builds/version.state persistiert, kann diese
// Stage die Umgebungsvariablen EFFECTIVE_MAJOR und EFFECTIVE_MINOR
// vor dem Build überschreiben. Ansonsten gelten die Parameter-Werte.
stage('Version bestimmen') {
steps {
script {
// Platzhalter: hier bei Bedarf State-Datei einlesen,
// z. B.:
// def state = readFile('/builds/version.state').trim()
// env.EFFECTIVE_MAJOR = state.split('\\.')[0]
// env.EFFECTIVE_MINOR = state.split('\\.')[1]
//
// Im Minimalbetrieb werden die Parameter-Werte verwendet:
echo "Buildversion: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER}"
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, sodass kein unaufgelöstes
// ${revision} in ~/.m2 verbleibt.
// allen installierten POMs auf.
sh "mvn clean verify -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER}"
}
}
} // stage: Maven Build
stage('SonarQube Analyse') {
steps {
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
withSonarQubeEnv('SonarQube') {
sh "mvn sonar:sonar -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} -Dsonar.projectKey=pdf-umbenenner -Dsonar.projectName='PDF KI Renamer'"
}
}
}
} // stage: SonarQube Analyse
stage('Publish PIT Coverage') {
steps {
recordCoverage(
tools: [[
parser: 'PIT',
pattern: '**/target/pit-reports/mutations.xml'
]],
id: 'pit',
name: 'PIT Mutation Coverage',
failOnError: true
)
}
} // stage: Publish PIT Coverage
stage('Archive JAR') {
steps {
// Bash wird explizit erzwungen (#!/usr/bin/env bash, set -euo pipefail),
// weil Jenkins-Agenten standardmäßig sh (dash) verwenden, das kein
// mapfile kennt. mapfile zählt exakt die gefundenen Shade-JARs und
// bricht den Build ab, wenn nicht genau eines vorhanden ist.
// 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
@@ -94,25 +132,10 @@ echo "Shade-JAR archiviert als: $JAR_NAME"
'''
archiveArtifacts artifacts: 'pdf-ki-renamer-*.jar', fingerprint: true
}
}
stage('Berichte') {
steps {
// JUnit-Testergebnisse einlesen
junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true
// JaCoCo-Coverage (falls im Build erzeugt)
// jacoco execPattern: '**/target/jacoco.exec'
// PIT-Mutationstest-Bericht (falls im Build erzeugt)
// publishHTML(target: [reportDir: 'target/pit-reports', ...])
}
}
} // stage: Archive JAR
stage('Artefakt ablegen') {
steps {
// JAR-Kopie in zentrales Build-Verzeichnis ablegen.
// Pfad /builds/ muss auf dem Jenkins-Agent gemountet sein.
sh '''#!/usr/bin/env bash
set -euo pipefail
@@ -122,19 +145,39 @@ 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 {
// Lokale JAR-Kopie im Workspace entfernen (Artefakt ist archiviert)
sh '''#!/usr/bin/env bash
set -euo pipefail
rm -f pdf-ki-renamer-*.jar
echo "Aufräumen abgeschlossen."
'''
}
}
}
} // stage: Aufräumen
} // stages
post {
success {
@@ -144,8 +187,8 @@ echo "Aufräumen abgeschlossen."
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} fehlgeschlagen."
}
always {
// Workspace nach Abschluss bereinigen
cleanWs()
}
deleteDir()
}
}
} // pipeline
+168
View File
@@ -0,0 +1,168 @@
# AP-A Token-Tracking Fundament Zusammenfassung
Dieses Dokument fasst alle Klassen, Methoden und Dateien zusammen, die im Zuge
von AP-A (Token- und Kosten-Tracking-Fundament der V3.3-Spezifikation, #74)
neu erstellt oder substanziell erweitert wurden.
## Schema-Migration
- `pdf-umbenenner-adapter-out/src/main/resources/db/migration/V2__token_tracking.sql`
- Sechs neue Spalten in `processing_attempt`:
`input_tokens`, `output_tokens`,
`cache_creation_input_tokens`, `cache_read_input_tokens`,
`price_input_per_token_nano_usd`, `price_output_per_token_nano_usd`.
- Neue Tabelle `model_price` mit Composite Primary Key
`(provider, model_name)`, NOT-NULL-Preisen, Currency-CHECK auf `'USD'`,
`updated_at`-Spalte.
- Zwei zusätzliche Indizes auf `processing_attempt`:
`idx_processing_attempt_started_at_provider_fp_model`,
`idx_processing_attempt_run_id_provider_model`.
- Default-Preise für gpt-4o-mini, gpt-4o, gpt-4.1*, gpt-5*, claude-haiku-4-5,
claude-sonnet-4-6 und claude-opus-4-7 (`ON CONFLICT DO NOTHING`).
## Application-Modul
### DTOs (`application/dto`)
- `AiUsageMetadata` Token-Verbrauchsmetadaten mit `empty()`,
`hasAnyTokenData()`, `hasCacheTokens()`.
- `ModelPriceEntry` Schreib-/Validierungs-DTO mit Wertgrenzen-Validierung im
Konstruktor.
- `ModelPriceView` Lese-/Anzeige-DTO mit nullable `updatedAt` und
`invalidUpdatedAt`-Flag.
- `ModelPriceKey` Composite-Key für Löschungen.
- `ModelPriceChangeSet` atomarer Block aus Upserts und Deletions, defensive
Listen-Kopie.
### Cost-Komponenten (`application/cost`)
- `CostResult` interpretierte Kosten-Anzeige mit Status-Flags.
- `CostCalculator` `formatRow(...)` und `calculateAttempt(...)` (echt
implementiert), `formatTotal(...)` als Stub für AP-B.
### Ports (`application/port/out`)
- `ModelPriceRepository` `findAll`, `findByProviderAndModelName`, `upsert`,
`delete`, `saveAllChanges`.
- `AiInvocationSuccess` (erweitert) neues Feld `usageMetadata`.
### Use Cases (`application/usecase`)
- `DefaultManageModelPricesUseCase` CRUD-Fassade mit ChangeSet-Konflikt-
validierung (vier Regeln) und Provider-Whitelist beim Upsert.
- `ModelPriceValidationException` deutsche Validierungsfehler-Exception.
### Application-Service-Anpassungen
- `AiNamingService` (erweitert) reicht `AiUsageMetadata` aus dem
`AiInvocationSuccess` als Token-Felder in den `AiAttemptContext` weiter.
- `DocumentProcessingCoordinator` (erweitert)
- neuer optionaler Konstruktor mit `ModelPriceRepository` und
`headlessMode`-Flag.
- `loadPriceSnapshot(modelName)` lädt Snapshot-Preis pro Versuch; Lookup-
Fehler liefern leeren Snapshot ohne Attempt-Verlust.
- `buildAttempt(...)` befüllt jetzt Token- und Preis-Snapshot-Felder im
`ProcessingAttempt`.
### Domain-Anpassungen
- `AiAttemptContext` (erweitert) vier nullable Token-Felder
(`inputTokens`, `outputTokens`, `cacheCreationInputTokens`,
`cacheReadInputTokens`); Backward-compatible Convenience-Konstruktor.
- `ProcessingAttempt` (erweitert) sechs nullable Token-/Preis-Snapshot-
Felder; Convenience-Konstruktor und `withoutAiFields(...)` ohne Verhaltens-
änderung.
## Adapter-Out-Modul
- `SqliteConnectionFactory` (neu) zentrale Connection-Factory; setzt
`PRAGMA journal_mode=WAL` und `PRAGMA busy_timeout=5000`.
Foreign-Key-Pragma wird bewusst nicht implizit gesetzt (Verhalten der
bisherigen `DriverManager.getConnection`-Stellen erhalten).
- `SqliteUnitOfWorkAdapter`, `SqliteProcessingAttemptRepositoryAdapter`,
`SqliteHistoryQueryAdapter`, `SqliteDocumentRecordRepositoryAdapter`
(jeweils geändert) nutzen die neue Factory.
- `SqliteProcessingAttemptRepositoryAdapter.save()` (erweitert)
INSERT um sechs neue Spalten erweitert, neue Hilfsmethode
`setNullableLong(...)`.
- `SqliteHistoryQueryAdapter.mapToProcessingAttempt(...)` (erweitert)
liest die sechs neuen Spalten via `readNullableLong(...)`.
- `SqliteSchemaInitializationAdapter` (geändert) erwartete Spalten/Indizes
bleiben am V1-Zielschema; Doc-Klarstellung, dass V2 additiv auf der Baseline
arbeitet.
- `SqliteModelPriceRepositoryAdapter` (neu) `findAll`,
`findByProviderAndModelName`, `upsert`, `delete`, `saveAllChanges`
(UPSERT via `ON CONFLICT(provider, model_name) DO UPDATE`, transaktionaler
Batch). Lese-Mapping behandelt `DateTimeParseException` als
`invalidUpdatedAt`.
- `ModelPriceRepositoryException` (neu) technischer JDBC-Fehler.
### KI-Adapter
- `AnthropicClaudeHttpAdapter` (geändert) neue Methode
`extractTokenUsageFromResponse(JSONObject)` für `usage.input_tokens`,
`usage.output_tokens`, `usage.cache_creation_input_tokens`,
`usage.cache_read_input_tokens` mit Validierung (negativ, > 10 Mio.,
nicht-numerisch → NULL + WARN).
- `OpenAiHttpAdapter` (geändert) analoge Methode mit Mapping
`prompt_tokens → input_tokens`, `completion_tokens → output_tokens`;
Cache-Felder bleiben null.
## GUI-Modul
### Neuer Tab "Modell-Preise"
- `adapter-in-gui/modelprices/GuiModelPriceManagementPort` (neu)
Bridge-Port für GUI-Zugriff auf Modell-Preise.
- `adapter-in-gui/modelprices/GuiModelPricesTab` (neu) TableView mit
editierbaren Preisspalten (`In/1M USD`, `Out/1M USD`), Lösch-Button mit
Bestätigungsdialog, Add-Dialog mit Provider-Auswahl, Speichern-Aktion über
`ModelPriceChangeSet`. Konvertierung Nano-USD ↔ `$/1M Tokens` mit
HALF-UP-Rundung; unbekannte Provider werden read-only mit Tooltip
angezeigt; `updatedAt = null` als "ungueltig".
### Anbindung im Workspace
- `GuiConfigurationEditorWorkspace` (geändert) sechster Tab "Modell-
Preise" wird angelegt; neue Methode `warnIfActiveModelHasNoPriceEntry()`
zeigt vor dem Speichern eine deutsche Warnung an, wenn das aktuell
ausgewählte Modell keinen Preis-Eintrag besitzt.
- `GuiStartupContext` (geändert) neues optionales Feld
`modelPriceManagementPort` mit Backward-Kompatibilität.
- `BootstrapRunner` (geändert) neue Methode
`buildGuiModelPriceManagementPort()` und Helfer für die Verdrahtung;
Coordinator wird mit `ModelPriceRepository` und `headlessMode`-Flag
versorgt.
### History-Tab
- `GuiHistoryTab` (geändert) drei zusätzliche Spalten in der
Versuchstabelle: Input-Tokens, Output-Tokens, Kosten. Cache-only-Versuche
zeigen "nur Cache-Tokens, keine Standardkosten"; fehlender Preis-Snapshot
führt zu "Preis fehlt"; Mikrobeträge als "< $0.0001"; Cache-Beteiligung
ergänzt Suffix "(ohne Cache-Anteil)".
### Summary-Banner
- `BatchRunSummaryBanner` (geändert) aus einzeiliger HBox wurde eine
vierzeilige VBox: Status-Zeile, Token-Zeile, Kosten-Zeile, optionale
Cache-only-Zeile. Neue Record-Klasse `BatchRunTokenSummary` mit
`empty()`-Default; bestehende `update(Map)`-Aufrufer bleiben funktionsfähig.
## Testanpassungen
- `pdf-umbenenner-application/.../service/AiNamingServiceTest` und
`pdf-umbenenner-bootstrap/.../e2e/StubAiInvocationPort` alte
`AiInvocationSuccess`-Konstruktoraufrufe um `AiUsageMetadata.empty()`
ergänzt.
- `SqliteSchemaInitializationAdapterTest.fall1_leereDb_processingAttemptHatAlleErwartetenSpalten`
prüft jetzt zusätzlich die sechs Token-/Preis-Spalten.
- `GuiAdapterSmokeTest.editorWorkspace_startStateShowsEmptyHeaderDefaultsAndOneTab`
erwartet jetzt sechs Tabs inkl. "Modell-Preise".
## Build und Verifizierung
- `mvn clean verify` läuft auf dem Reactor `pdf-umbenenner-parent` durch
(Tests grün auf allen Modulen).
- Commit `08ec021` auf `main` gepusht.
## Bewusst ausgesparte Bereiche (für AP-B / AP-C)
- `CostCalculator.formatTotal(...)` ist ein Stub und wirft
`UnsupportedOperationException`.
- `TokenStatisticsReadModelPort`, `QueryCostAnalysisFullUseCase`,
`QueryCostAnalysisHeaderOnlyUseCase`, `QueryRunSummaryUseCase`,
`SqliteTokenStatisticsReadModelAdapter` sind nicht enthalten.
- Summary-Banner zeigt aktuell `0/0` Tokens und `$0.0000` Kosten, da das
Read-Model erst in AP-B verdrahtet wird.
- CLI-Befehle für Modell-Preise (#99) und Modell-Combobox-Filter (#98)
sind AP-C.
+42 -5
View File
@@ -120,12 +120,19 @@ ausschließlich der Bootstrap (→ `AiProviderSelector`).
#### SQLite
- **`...sqlite.SqliteSchemaInitializationAdapter`** legt Tabellen `document_record` und
`processing_attempt` an. Schema-Evolution erfolgt per `ALTER TABLE ADD COLUMN`; bestehende
Datenbestände bleiben rückwärtskompatibel.
- **`...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.
`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.).
@@ -134,6 +141,12 @@ ausschließlich der Bootstrap (→ `AiProviderSelector`).
ü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`.
@@ -145,6 +158,15 @@ ausschließlich der Bootstrap (→ `AiProviderSelector`).
(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
@@ -207,6 +229,11 @@ ausschließlich der Bootstrap (→ `AiProviderSelector`).
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.
@@ -267,8 +294,18 @@ DI-Framework verwendet.
- `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, kein Run-Lock-Erwerb, kein Batch-Use-Case
- 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
+25 -2
View File
@@ -33,6 +33,7 @@ Definiert Use-Case-Orchestrierung sowie alle Inbound- und Outbound-Ports der hex
| `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`) |
@@ -106,6 +107,25 @@ Record für einen Versuchshistorie-Eintrag; enthält u. a. Provider-Identifikato
**`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
@@ -142,7 +162,7 @@ Alle Outbound-Ports liegen in `de.gecheckt.pdf.umbenenner.application.port.out`
| `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 | `PromptLoadingResult loadPrompt()` |
| `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()` |
@@ -154,8 +174,11 @@ Alle Outbound-Ports liegen in `de.gecheckt.pdf.umbenenner.application.port.out`
| `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` und `ApiKeyResolutionPort` werden ausschließlich im GUI-Pfad genutzt. Ihre Implementierungen und der Aufrufkontext sind in `gui-overview.md` beschrieben.
> **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`.
---
+49 -9
View File
@@ -25,20 +25,33 @@ 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.
── 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
@@ -49,8 +62,10 @@ de.gecheckt.pdf.umbenenner.adapter.in.gui
|---|---|
| `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. Bietet `blank()`-Fabrikmethode für Tests. |
| `GuiConfigurationEditorWorkspace` | Herzstück der Oberfläche. Baut `TabPane` mit Editor-Tab und Verarbeitungslauf-Tab, verwaltet `GuiConfigurationEditorState`, koordiniert Lade- und Schreibvorgänge auf Worker-Threads, steuert Dirty-State-Anzeige und Fenstertitel. |
| `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. |
@@ -70,10 +85,18 @@ de.gecheckt.pdf.umbenenner.adapter.in.gui
|---|---|
| `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
@@ -92,6 +115,8 @@ Alle blockierenden Operationen laufen auf benannten Daemon-Threads außerhalb de
| 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
@@ -137,13 +162,28 @@ Durch diese Injektion sind Unit-Tests vollständig ohne JavaFX-Runtime möglich.
|---|---|
| `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 einen Statusreset ohne Folge-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. |
Alle Implementierungen dieser Interfaces liegen in `pdf-umbenenner-bootstrap` oder `pdf-umbenenner-adapter-out`. Das GUI-Modul kennt ausschließlich die Interface-Typen.
### 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.
---
+208 -4
View File
@@ -63,7 +63,7 @@ mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
### Umfang der GUI
Die GUI enthält zwei Tabs:
Die GUI enthält fünf Tabs:
- **Tab „Konfiguration"** Editor, Validierungs- und technische Testoberfläche für
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
@@ -75,12 +75,58 @@ Die GUI enthält zwei Tabs:
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
- **Tab „Scheduler"** Optionaler automatischer Scheduler für periodische Verarbeitungsläufe.
Kann gestartet, gestoppt und mit einem konfigurierten Intervall betrieben werden. Während
der Scheduler aktiv ist, sind Tab 1 „Konfiguration" und der manuelle Lauf gesperrt.
Erfordert `scheduler.enabled=true` und ein gültiges `scheduler.interval.seconds` in der
gespeicherten Konfiguration.
- **Tab „Verlauf"** Ansicht aller bisher verarbeiteten Dokumente mit Status, Dateinamen
und Verarbeitungsdetails direkt aus der SQLite-Datenbank. Ermöglicht Status-Reset und
Löschung einzelner Einträge.
- **Tab „Prompt"** Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt
aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel).
Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`).
Ein „Auf Standard zurücksetzen"-Button befüllt die TextArea mit der Standard-Vorlage,
ohne zu speichern. Fehlt die Prompt-Datei am konfigurierten Pfad, wird ein
„Standard-Prompt erstellen"-Button angezeigt. Der Tab wird beim ersten Öffnen automatisch
geladen. Tab-Wechsel mit ungespeicherten Änderungen löst einen Bestätigungsdialog aus.
Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und
kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau
ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf
wird jedoch nicht technisch erkannt oder blockiert.
### Automatischer Scheduler
Der GUI-Tab „Scheduler" ermöglicht den Betrieb eines optionalen, periodisch laufenden
Schedulers, der automatisch Verarbeitungsläufe anstößt.
**Konfigurationsparameter:**
| Parameter | Beschreibung | Standard |
|---|---|---|
| `scheduler.enabled` | Scheduler im GUI-Modus aktivieren (`true`/`false`); wird im headless Betrieb ignoriert | `false` |
| `scheduler.interval.seconds` | Intervall zwischen automatischen Läufen in Sekunden (Integer >= 30; Pflicht wenn `scheduler.enabled=true`); wird im headless Betrieb ignoriert | |
Ungültige Werte (kein Integer, < 30 oder leer bei `scheduler.enabled=true`) verhindern den
Scheduler-Start und werden im GUI-Tab als Fehler gemeldet.
**Autostart:** Ist `scheduler.enabled=true` in der gespeicherten Konfiguration, startet der
Scheduler automatisch, wenn die Konfiguration beim GUI-Start geladen wird. Der erste
Verarbeitungslauf beginnt **unmittelbar** nach dem Scheduler-Start (kein initiales Warten).
**Headless-Betrieb:** Im headless Betrieb werden `scheduler.enabled` und
`scheduler.interval.seconds` vollständig ignoriert. Der Scheduler ist ausschließlich im
GUI-Modus verfügbar.
**Sperrverhalten:** Solange der Scheduler aktiv ist, ist Tab 1 „Konfiguration" gesperrt
(Bearbeitungssperre mit Hinweisbanner). Manuelles Starten eines Laufs ist ebenfalls nicht
möglich. Nach dem Stoppen des Schedulers werden beide Sperren automatisch aufgehoben.
**Schließen der Anwendung:** Versucht der Benutzer das Fenster zu schließen, während der
Scheduler aktiv ist oder ein Lauf läuft, erscheint ein Informationsdialog. Das Schließen
wird blockiert, bis der Scheduler gestoppt und kein Lauf mehr aktiv ist.
---
## Voraussetzungen
@@ -222,6 +268,8 @@ Nur der **aktive** Provider muss vollständig konfiguriert sein. Der inaktive Pr
| `log.directory` | Log-Verzeichnis | `./logs/` |
| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` |
| `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` |
| `scheduler.enabled` | Scheduler im GUI-Modus aktivieren (`true`/`false`); wird im headless Betrieb ignoriert | `false` |
| `scheduler.interval.seconds` | Intervall in Sekunden (Integer >= 30; Pflicht wenn `scheduler.enabled=true`); wird im headless Betrieb ignoriert | |
### API-Schlüssel
@@ -292,6 +340,35 @@ Die Anwendung ergänzt den Prompt automatisch um:
- einen Dokumenttext-Abschnitt
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
### Prompt-Pfad-Auflösung je Betriebsart
Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst,
wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab:
| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert |
|---|---|---|
| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` |
| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` |
| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` |
| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen |
> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad**
> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis
> immer eindeutig auffindbar insbesondere beim Start über den Windows Task Scheduler,
> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann.
### Bearbeitung über den GUI-Prompt-Tab
Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und
gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn
das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem
Fall wird kein stiller Fallback durchgeführt).
Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert
war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"),
aktualisiert sich der Prompt-Tab nicht automatisch in diesem Fall sollte die GUI neu gestartet
oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden.
---
## Zielformat
@@ -389,7 +466,27 @@ Die Anwendung verwendet eine exklusive Lock-Datei, um parallele Instanzen zu ver
Wenn bereits eine Instanz läuft, beendet sich die neue Instanz sofort mit Exit-Code `1`.
Der Pfad der Lock-Datei ist über `runtime.lock.file` konfigurierbar.
Ohne Konfiguration wird `pdf-umbenenner.lock` im Arbeitsverzeichnis verwendet.
### Pfadauflösung der Lock-Datei
| Pfadtyp | Verhalten |
|---|---|
| **Absoluter Pfad** | Wird direkt verwendet. Schlägt das Anlegen der Lock-Datei fehl, bricht der Start mit einer klaren Fehlermeldung ab kein Fallback. |
| **Relativer oder unkonfigurierter Pfad** | Zweistufige Auflösung: (1) relativ zum Verzeichnis der JAR-Datei (`CodeSource.getLocation()`), (2) Fallback auf das Benutzerverzeichnis (`user.home`). Erst wenn auch `user.home` fehlschlägt, bricht der Start ab. |
Fehlende übergeordnete Verzeichnisse werden automatisch angelegt.
Der tatsächlich verwendete absolute Pfad der Lock-Datei wird beim Start auf INFO-Level geloggt, z. B.:
```
Lock-Datei: C:\Users\Funny\Documents\pdf-umbenenner.lock
```
Diese Auflösungslogik gilt sowohl für den GUI- als auch für den headless Start.
> **Empfehlung für den MSI-Betrieb:** Da das Installationsverzeichnis `C:\Program Files\`
> schreibgeschützt ist, muss `runtime.lock.file` als absoluter Pfad auf ein beschreibbares
> Verzeichnis zeigen (z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock`).
---
@@ -399,11 +496,50 @@ Die SQLite-Datei enthält:
- **Dokument-Stammsätze**: Gesamtstatus, Fehlerzähler, letzter Zieldateiname, Zeitstempel
- **Versuchshistorie**: Jeder Verarbeitungsversuch mit Modell, Prompt-Identifikator,
KI-Rohantwort, Reasoning, Datum, Titel und Fehlerstatus
KI-Rohantwort, Reasoning, Datum, Titel, Fehlerstatus und Fehlerdetails
Die Datenbank ist die führende Wahrheitsquelle für Bearbeitungsstatus und Nachvollziehbarkeit.
Sie muss nicht manuell verwaltet werden das Schema wird beim Start automatisch initialisiert.
### Fehlerursache im Verlauf-Tab
Verarbeitungsversuche mit Status `FAILED_FINAL`, `FAILED_RETRYABLE` oder
`SKIPPED_FINAL_FAILURE` speichern eine nutzerverständliche Fehlerursache
(`failure_details`). Diese wird im Verlauf-Tab im Detailbereich des jeweiligen
Dokuments angezeigt. Ältere Einträge ohne Fehlerdetails zeigen einen Platzhaltertext.
Fehlerdetails werden auf 1000 Zeichen begrenzt und enthalten keine rohen
Provider-Meldungen oder API-Schlüssel.
### Neue Datenbank anlegen
Über den Menüpunkt **Datenbank → Neue Datenbank anlegen...** kann aus der GUI
heraus eine neue, leere SQLite-Datenbank erstellt und sofort aktiviert werden,
ohne die Anwendung neu zu starten.
**Ablauf:**
1. Dateidialog öffnet (Filter: `*.sqlite` und `*.db`); Zieldatei wählen oder eingeben.
2. Sicherheitsprüfung: aktive und gewählte Datei werden normalisiert verglichen
(case-insensitive unter Windows). Bei Übereinstimmung erscheint eine Fehlermeldung.
3. Bei bereits existierender Fremddatei: Bestätigungsdialog „Die Datei existiert bereits.
Überschreiben?"
4. Neue SQLite-Datei wird als temporäre Datei erzeugt, Flyway führt alle Migrationsskripte
auf neuesten Stand aus, dann Verbindungstest.
5. Nach erfolgreichem Test: atomarer Move zur Zieldatei.
6. Aktive Datenbankverbindung der Anwendung wechselt zur neuen DB.
7. Der Verlauf-Tab lädt neu und zeigt „Noch keine Verarbeitungen vorhanden."
8. Die Statuszeile aktualisiert den DB-Pfad.
> **Wichtig:** Die Konfigurationsdatei wird durch den Wechsel automatisch als geändert
> markiert. **Konfiguration speichern**, damit die neue Datenbank beim nächsten Start
> der Anwendung verwendet wird.
**Fehlerfall:** Schlägt ein Schritt fehl, bleibt die bisherige Datenbank unverändert
in Betrieb. Die temporäre Datei wird gelöscht. Ein Fehlerdialog erscheint.
Der Menüpunkt ist nur aktiv, wenn kein Verarbeitungslauf läuft.
Der headless Betrieb ist von dieser Funktion nicht betroffen.
---
## Build und Packaging
@@ -514,6 +650,74 @@ Installationsverzeichnis ab. **Der Betreiber muss diese Beispieldatei manuell na
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
**Empfehlung für Pfade im MSI-Betrieb:**
Für den MSI-Betrieb (Startmenü, Task Scheduler) müssen alle Dateipfade als **absolute Pfade**
konfiguriert werden. Relative Pfade werden relativ zum Installationsverzeichnis
`C:\Program Files\PDF KI Renamer\` aufgelöst, das **schreibgeschützt** ist. Dadurch
schlagen Schreibversuche (Logs, SQLite-Datenbank, Lock-Datei) ohne Fehlermeldung fehl.
> **Warnung Relative Pfade im MSI-Betrieb nicht verwenden:**
> Pfade wie `./logs`, `./work/local/logs` oder `logs/` werden im MSI-Betrieb relativ
> zum Installationsverzeichnis aufgelöst. Das Installationsverzeichnis ist für normale
> Benutzerkonten schreibgeschützt. Log4j2 scheitert dann still, ohne eine sichtbare
> Fehlermeldung zu erzeugen.
> **Warnung Backslashes in `.properties`-Dateien:**
> In Java-`.properties`-Dateien werden Backslashes (`\`) als Escape-Zeichen interpretiert.
> Windows-Pfade wie `C:\Users\Funny\Logs` müssen entweder mit Forward-Slashes
> (`C:/Users/Funny/Logs`) oder mit doppelten Backslashes (`C:\\Users\\Funny\\Logs`)
> angegeben werden. Einfache Backslashes werden stillschweigend falsch interpretiert.
Betroffene Parameter:
| Parameter | Empfehlung |
|---|---|
| `log.directory` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/logs` |
| `runtime.lock.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock` |
| `prompt.template.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/prompts/template.txt` |
| `sqlite.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/pdf-umbenenner.db` |
Das empfohlene Konfigurationsverzeichnis für alle schreibbaren Daten im MSI-Betrieb ist
`C:\ProgramData\PDF KI Renamer\`, da dieses Verzeichnis standardmäßig für alle
Benutzerkonten schreibbar ist und bei der Deinstallation erhalten bleibt.
**Diagnose: Log-Datei-Prüfpunkt in den technischen Tests**
Die technischen Tests (Schaltfläche „Technische Tests ausführen" im Konfigurationseditor)
enthalten einen dedizierten Prüfpunkt **„Log-Verzeichnis beschreibbar"**, der anzeigt:
- den konfigurierten `log.directory`-Wert (roh und als aufgelöster absoluter Pfad),
- ob das Verzeichnis vorhanden und beschreibbar ist,
- den tatsächlichen Log-Dateipfad aus der laufenden Log4j2-Konfiguration.
Ein nicht beschreibbares Log-Verzeichnis wird als **Warnung** angezeigt, nicht als Fehler
(die Anwendung kann ohne Datei-Logging laufen). Der Prüfpunkt hilft, den typischen
MSI-Betriebsfehler relatives `log.directory` auf schreibgeschütztem Installationspfad
frühzeitig zu erkennen.
### MSI-Release-Checkliste
Die folgende Checkliste ist vor jeder MSI-Auslieferung manuell abzuarbeiten.
- [ ] Neuinstallation auf sauberer Windows-Umgebung ohne vorinstalliertes Java
- [ ] Installation in Installationspfad **mit Leerzeichen** (z. B. `C:\Program Files\PDF KI Renamer\`)
- [ ] Upgrade von installiertem Vorgänger-MSI (kein manuelles Deinstallieren)
- [ ] GUI-Start über Startmenü-Eintrag
- [ ] Headless-Start über `PDF-KI-Renamer.bat` im Windows Task Scheduler
- [ ] Desktop-Shortcut vorhanden oder Einschränkung hier dokumentiert
- [ ] App-Version `3.0.x` im Windows-Installer sichtbar („Programme und Features")
- [ ] Deinstallation sauber Konfiguration unter `C:\ProgramData\PDF KI Renamer\` bleibt erhalten
- [ ] SmartScreen-Warnung erscheint und wird durch „Weitere Informationen → Trotzdem ausführen" bestätigt
- [ ] BAT-Dateien funktionieren bei Installationspfad mit Leerzeichen
- [ ] Anwendungsstart **ohne Entwicklungs-JDK** erfolgreich: GUI-Start, PDF laden und rendern, Verarbeitungslauf starten, Verlaufs-Tab öffnen (Verifikation der `addModules`-Liste)
> **Hinweis zur JDK-freien Laufzeit-Verifikation:** Nur ein erfolgreicher Test
> auf einem System ohne installiertes JDK bestätigt die Vollständigkeit der
> `addModules`-Liste in `pdf-umbenenner-packaging/pom.xml`. Die aktuelle Liste
> wurde per `jdeps --print-module-deps --ignore-missing-deps` ermittelt;
> vollständige Ausgabe in `pdf-umbenenner-packaging/jdeps-output.txt`.
### Build-Kommandos
**Vollständiger Reactor-Build** (alle Module, Tests, Packaging):
@@ -610,7 +814,7 @@ Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md)
- Keine eingebaute OCR-Funktion
- Kein Web-UI, keine REST-API
- Die GUI ermöglicht Konfiguration, Validierung, technische Diagnose und die Ausführung von Verarbeitungsläufen mit integrierter PDF-Vorschau und editierbarem Dateiname
- Kein interner Scheduler der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`)
- Kein interner Scheduler im headless Betrieb der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`); im GUI-Modus steht optional ein interner Scheduler zur Verfügung (Tab „Scheduler")
- Quelldateien werden nie überschrieben, verschoben oder gelöscht
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
- Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet
+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.
+166
View File
@@ -0,0 +1,166 @@
# Freigabedokument V3.1 PDF-Umbenenner
## Geprüfter Stand
- Git-Branch: `main`
- Versionsnummer: `3.1.267`
- Freigabedatum: 2026-05-06
- **Status:** freigegeben
---
## Zielsetzung von V3.1
V3.1 ist der konsequente Nachschlag zu V3.0: Was der Produkttest aufgedeckt hat,
wird bereinigt. Kein großes Architektur-Feature, kein neues Maven-Modul
gezielter UX-Schliff und Robustheit in drei Schwerpunkten:
1. **UX-Polishing** sichtbare Schwächen aus dem V3.0-Produkttest behoben
(#77, #80, #81, #83, #84, #88, #91)
2. **Verlauf-Tab reifen lassen** Suche, Mehrfachauswahl, DB-Neuanlage
(#82, #86, #87)
3. **Quick Win** Mausrad-Zoom im PDF-Viewer als wertvoller Gebrauchskomfort
(#32)
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt vollständig unverändert.
Hexagonale Architektur, Modulstruktur, headless-Betrieb, `.properties`-
Konfigurationswahrheit und Flyway-DB-Evolution bleiben unangetastet.
---
## Umgesetzte Issues
| # | Kategorie | Beschreibung |
|---|---|---|
| #32 | GUI | Strg+Mausrad-Zoom in der PDF-Vorschau: Delta-Akkumulation für Trackpad-Kompatibilität, ScrollEvent bei Strg immer konsumiert, Zoom 10500 %, Viewport-Mitte bleibt beim Zoom stabil, Fit-to-Width-Modus nach manuellem Zoom verlassen; Grab & Pan mit Handcursor im vergrößerten Zustand |
| #77 | UX | Vollständige Bestandsaufnahme aller interaktiven Elemente auf allen Tabs; fehlende Tooltips auf allen vier Tabs ergänzt; neue Konstanten ausschließlich in `GuiTooltipTexts`; TableColumn-Header über Column-Graphic-Pattern mit Label und Tooltip (kein Skin-/Lookup-Hack) |
| #80 | UX | Dirty-Indikator für den Konfigurations-Tab: Asterisk im Tab-Titel bei echter Nutzeränderung gegenüber Baseline-Snapshot; `loadingInProgress`-Flag verhindert unechte Dirty-State-Auslösung durch programmgesteuertes Laden; Bestätigungsdialog beim Verlassen mit ungespeicherten Änderungen; Kopplung mit DB-Pfad-Wechsel aus #87 |
| #81 | UX | Status-ComboBox und Versuche-Tabelle zeigen lesbare deutsche Anzeigetexte statt Enum-Rohnamen; alle acht Statuswerte über `ProcessingStatusPresentation` abgebildet; Status-ComboBox mit „Alle Status" als GUI-internem Null-Filter; DB-Queries intern weiterhin mit Enum-Namen |
| #82 | GUI | Live-Filter im Verlauf-Tab: 300 ms Debounce-Timer, Generation-Counter für Race-Condition-Schutz, veraltete Worker-Ergebnisse werden verworfen; Such-Button und Enter starten Suche sofort; Auswahl nach jeder neuen Suche vollständig geleert |
| #83 | UX | Leere KI-Begründung im Detailbereich zeigt `promptText`-Platzhalter statt leerem Feld; kein Vermischen von Nutzdaten und UI-Platzhaltertext; TextArea bleibt sichtbar |
| #84 | Bug | Aktionsbuttons im Verlauf-Tab werden nach Laufende ereignisgetrieben reaktiviert unabhängig vom Terminierungsgrund (Erfolg, Fehlerabbruch, Nutzerabbruch, Leerlauf); kein manueller Workaround notwendig |
| #86 | GUI | Mehrfachauswahl im Verlauf-Tab: `SelectionMode.MULTIPLE`, Strg+A nur bei Tabellenfokus (kein Konflikt mit Suchfeld), Schlüssel-Snapshot vor Worker-Thread-Start, Bulk-Reset und Bulk-Delete mit Bestätigungsdialog und Partial-Success-Zusammenfassung; Detailbereich zeigt Platzhalter bei Mehrfachauswahl |
| #87 | GUI | Neuer Menüpunkt „Datenbank → Neue Datenbank anlegen...": atomarer Ablauf via Temp-Datei, Flyway auf neuesten Schema-Stand, Verbindungstest, atomarer Move mit `ATOMIC_MOVE + REPLACE_EXISTING`; normalisierter case-insensitiver Pfadvergleich; DB-Busy-Sperre; Konfig-Tab wechselt in Dirty-State; Hinweismeldung nach Wechsel |
| #88 | UX | Fehlerursache für `FAILED_FINAL`, `FAILED_RETRYABLE` und `SKIPPED_FINAL_FAILURE` im Verlauf-Tab sichtbar; Flyway-Migration ergänzt Spalte `failure_details` in `processing_attempt`; Begrenzung auf 1000 Zeichen mit „…"-Kürzung vor Persistierung; keine rohen Provider-Meldungen oder API-Schlüssel persistiert; NULL-Einträge zeigen `promptText`-Platzhalter |
| #91 | Robustheit | Lock-File-Pfadauflösung: absoluter Pfad direkt ohne Fallback (Abbruch bei Fehler); relativer oder unkonfigurierter Pfad zweistufig (JAR-Verzeichnis → `user.home` → Abbruch); fehlende Parent-Verzeichnisse automatisch angelegt; tatsächlich verwendeter absoluter Pfad beim Start auf INFO-Level geloggt; gilt für GUI- und headless Start |
### Nachbesserung aus dem Produkttest
| # | Beschreibung |
|---|---|
| #93 | Produkttest-Nachbesserung: Korrekturen und Feinabstimmungen nach abgeschlossenem manuellem GUI-Produkttest gegen echte KI-Provider und echte PDFs |
---
## Architektur-Bilanz
| Neu | Anzahl | Bemerkung |
|---|---|---|
| Inbound-Port-Interfaces | 1 | `CreateNewDatabaseUseCase` |
| Application-Use-Cases | 1 | `DefaultCreateNewDatabaseUseCase` |
| Outbound-Ports | 2 | `DatabaseCreationPort`, `ActiveDatabaseContextPort` |
| Outbound-Adapter | 2 | `SqliteDatabaseCreationAdapter`, `SqliteActiveDatabaseContextAdapter` |
| GUI-Bridge-Interfaces | 1 | `GuiCreateNewDatabasePort` |
| Flyway-Migration | 1 | `failure_details TEXT` in `processing_attempt` (nächste freie Versionsnummer) |
Geänderte Komponenten (ausschließlich `adapter-in-gui`):
`GuiHistoryTab`, `GuiConfigTab`, `GuiTooltipTexts`, Verlauf-Detailbereich,
Status-ComboBox, PDF-Vorschau-Komponente, Lauf-Abschluss-Signalkette.
Nicht geändert: `pdf-umbenenner-domain` (außer ggf. minimaler Erweiterung für #88),
`pdf-umbenenner-adapter-in-cli`, headless-Verarbeitungslogik, fachliche Kernverarbeitung.
---
## Verbindlich verifizierte Spec-Punkte
- Kein Enum-Rohname in der GUI sichtbar alle acht Statuswerte tragen Displaytext
- `promptText` für leere Felder: kein Vermischen von Nutzdaten und Platzhaltertext
- Dirty-State Konfig-Tab: programmgesteuertes Laden löst kein Dirty-Flag aus
- Live-Filter: 300 ms Debounce, Generation-Counter, Auswahl nach Suche geleert
- Strg+A im Verlauf-Tab: nur bei Tabellenfokus (kein Konflikt mit Suchfeld)
- Schlüssel-Snapshot vor Bulk-Worker-Thread-Start
- DB-Anlage: normalisierter Pfadvergleich (case-insensitive, `toRealPath`/Parent-Normalisierung)
- DB-Anlage: `ATOMIC_MOVE + REPLACE_EXISTING`; kein halb-atomarer Fallback
- DB-Anlage: aktive DB bleibt bei Fehler vollständig unverändert
- Lock-File: absoluter Pfad direkt; relativer Pfad zweistufig; Pfad geloggt (INFO)
- Strg+Mausrad: ScrollEvent immer konsumiert; Delta-Akkumulation; 10500 %
- `failure_details`: max. 1000 Zeichen vor Persistierung; keine rohen Provider-Meldungen
- Aktionsbuttons nach Laufende ereignisgetrieben reaktiviert (alle Terminierungsgründe)
- Flyway ist die einzige Schema-Evolutionsquelle kein manuelles DDL im Code
- Code-Kommentare auf Deutsch; Logging auf Deutsch
- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
---
## Headless-Kompatibilität
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten.
Die `.properties`-Datei bleibt die einzige Konfigurationswahrheit. GUI-Code
initialisiert den headless Pfad nicht. Keine stillen Änderungen an Retry-Semantik,
Status-Persistenz oder fachlicher Verarbeitungslogik.
Von V3.1-Änderungen betroffener headless-Pfad: Lock-File-Pfadauflösung (#91)
und Flyway-Schemamigration für `failure_details` (#88) beide wirken beim
Programmstart unabhängig von GUI oder CLI.
---
## Datenbank-Migration
Flyway ergänzt die Tabelle `processing_attempt` um die Spalte `failure_details`:
```sql
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
```
- Bestehende Zeilen erhalten automatisch `NULL` kein Datenverlust.
- Ältere Einträge ohne Fehlerdetails zeigen in der GUI einen `promptText`-Platzhalter.
- Kein SQL-`CHECK`-Constraint (um Importdaten nicht zu blockieren).
- Begrenzung auf 1000 Zeichen wird ausschließlich vor Persistierung im Adapter erzwungen.
---
## Produkttest
**Produkttest: bestanden**
Manueller GUI-Produkttest gegen echte KI-Provider mit echten PDFs abgeschlossen.
Alle elf Issues und die Nachbesserung #93 wurden end-to-end verifiziert.
---
## Bekannte Einschränkungen
Keine.
---
## Nicht in V3.1
- Automatischer Scheduler / Quellordner-Überwachung (#22) → V3.x
- PDF-Viewer Render-DPI (#23) → V3.2
- F1-Hilfe (#69) → V3.2
- Dark Mode (#70) → V3.x
- Log-Viewer in der GUI (#72) → V3.2
- Token- und Kosten-Tracking (#74) → V3.2
- Excel-Export (#75) → V3.2
- Automatische Update-Prüfung (#76) → V3.2
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
- Änderung der fachlichen Kernverarbeitung des PDF-Umbenenners
---
## Nächste Version
**V3.2** geplante Schwerpunkte: PDF-Viewer Render-DPI, F1-Hilfe, Log-Viewer,
Token- und Kosten-Tracking, Excel-Export, automatische Update-Prüfung.
---
## Freigabeaussage
V3.1 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
bleibt unverändert gegenüber V3.0. Manueller Produkttest bestanden.
Keine Release-Blocker.
+170
View File
@@ -0,0 +1,170 @@
# Freigabedokument V3.2 PDF-Umbenenner
## Geprüfter Stand
- Git-Branch: `main`
- Versionsnummer: `3.2.300`
- Freigabedatum: 2026-05-07
- **Status:** freigegeben
---
## Zielsetzung von V3.2
V3.2 ist der Übergang vom manuellen Batch-Tool zur autonomen
Dauerläufer-Anwendung. Ein einziges, klar abgegrenztes Hauptfeature:
**#22 Automatischer Scheduler:** Die Anwendung überwacht den konfigurierten
Quellordner dauerhaft im Hintergrund und startet die Verarbeitungspipeline
automatisch, sobald neue PDF-Dateien erkannt werden. Der Nutzer steuert
den Scheduler ausschließlich über den neuen Tab „Scheduler".
V3.2 ist eine reine Scheduler-Veranstaltung. Token- und Kosten-Tracking (#74)
wurde bewusst herausgelöst und bekommt eine eigene saubere Spezifikation in
V3.x inklusive Modell-Preistabelle, Persistenz-Strategie und EUR-Währung.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt vollständig
unverändert. Hexagonale Architektur, Modulstruktur, headless-Betrieb,
`.properties`-Konfigurationswahrheit und Flyway-DB-Evolution bleiben
unangetastet.
---
## Umgesetzte Features
| # | Kategorie | Beschreibung |
|---|---|---|
| #22 | Hauptfeature | Automatischer Scheduler: `ScheduledExecutorService`-Polling mit `scheduleWithFixedDelay`; Initial Delay 0 (erster Tick sofort); konfigurierbares Intervall (Minimum 30 s); neuer Tab „Scheduler" mit Start/Stop, Statusanzeige, Countdown, letzter Lauf, Gesamtzähler; OS-Lock auf `.properties` während Scheduler läuft; Konfig-Tab read-only bei aktivem Lock; manuelle Läufe bei aktivem Scheduler gesperrt; App-Schließen-Guard |
### Neue Architektur-Komponenten
| Neu | Anzahl | Bemerkung |
|---|---|---|
| Neues Maven-Modul | 1 | `pdf-umbenenner-adapter-in-scheduler` |
| Inbound-Port-Interfaces | 1 | `SchedulerControlUseCase` |
| Application-Use-Cases | 1 | `DefaultSchedulerControlUseCase` |
| Outbound-Ports | 3 | `SchedulerPort`, `ConfigurationFileLockPort`, `SchedulerSettingsPort` |
| Funktionale Interfaces | 1 | `BatchRunTrigger` mit sealed `BatchRunTriggerResult` |
| Neue Adapter | 2 | `ScheduledExecutorServiceSchedulerAdapter`, `FileChannelConfigurationAccessAdapter` |
| GUI-Komponenten neu | 2 | `GuiSchedulerTab`, `GuiStatusRefreshTimeline` |
| Bootstrap-Refactoring | | Init/Run-Trennung: `GuiShellContext` immer, `ApplicationRunContext` bei valider Config; `GuiApplicationContextInitializer`-Callback für Auto-Load-Pfad |
| Flyway-Migration | 0 | Keine DB-Migration in V3.2 |
Kontrollierte Architekturausnahme: CLAUDE.md wurde um die Scheduler-Ausnahme
erweitert. „Keine Dauerlauf-Anwendung" und „kein interner Scheduler" gelten
ab V3.2 nur noch für den headless-Pfad.
### Zusätzliche Verbesserungen (Produkttest-Nachbesserungen)
| Beschreibung |
|---|
| `ApplicationRunContext` wird nun auch beim Auto-Load-Pfad (ohne `--config`) korrekt aufgebaut via `GuiApplicationContextInitializer`-Callback |
| Double-Lock-Bug im `BatchRunTrigger`-Lambda behoben: kein eigenes `tryAcquire()` mehr, Lock ausschließlich in `execute()` |
| Stop-Button-Wiring-Bug behoben: `GuiStatusRefreshTimeline` liest jetzt den Live-Use-Case aus dem Workspace statt aus dem unveränderlichen `GuiStartupContext` |
| `installSchedulerCloseGuard` analog gefixt (gleiches Wiring-Problem) |
| `loadHistoryOverviewForGui` und 6 weitere GUI-Methoden im `BootstrapRunner` nutzen bei vorhandenem `ApplicationRunContext` direkt den Repository-Adapter statt Config neu zu laden verhindert IOException bei aktivem Config-Lock |
| Autostart-Feature entfernt: Scheduler startet nie automatisch, immer nur auf explizite Nutzeraktion |
| `RunSummary`-Zählung im Scheduler-Tab korrigiert: `PROPOSAL_READY` zählt korrekt als Erfolg; Gesamtzähler seit Scheduler-Start eingeführt |
| Java-Preferences-Knoten auf fixen String `de/gecheckt/pdf-umbenenner` umgestellt verhindert Verlust des gespeicherten Config-Pfads nach Code-Änderungen |
---
## Verbindlich verifizierte Spec-Punkte
- Scheduler startet nur auf explizite Nutzeraktion kein Autostart
- Erster Tick läuft sofort nach Scheduler-Start (Initial Delay 0)
- `scheduleWithFixedDelay`: nächster Tick erst N Sekunden nach Laufende
- Laufkollision via nicht-blockierendem `RunLockPort.tryAcquire()` kein Queuing
- Manuelle Läufe bei aktivem Scheduler gesperrt (deterministisches Verhalten)
- OS-Lock auf `.properties` während Scheduler läuft: Konfig-Tab read-only,
Speichern-Button deaktiviert, Eingabefelder nicht editierbar
- Verlauf-Tab funktioniert korrekt bei aktivem Config-Lock
- Stop während aktivem Lauf: Batch läuft zu Ende, danach `STOPPED`
- App-Schließen bei aktivem Scheduler: Hinweisdialog, App schließt nicht
- `SchedulerStatus` als immutable Snapshot via `AtomicReference`
- `SchedulerState` mit 5 Werten: `STOPPED`, `STARTING`, `RUNNING_IDLE`,
`RUNNING_BATCH_ACTIVE`, `STOPPING_BATCH_ACTIVE`
- No-op-Lauf (keine Kandidaten): „keine neuen Dokumente"; kein Fehlerstatus
- Scheduler-Tab zeigt korrekte Anzeige: letzter Lauf + Gesamtzähler
- Exception im Tick: gefangen, ERROR-geloggt, Executor läuft weiter
- Non-Daemon-Thread; sauberer Shutdown via `awaitTermination`
- Kein JavaFX im Modul `adapter-in-scheduler`
- PIT im neuen Modul explizit deaktiviert
- Code-Kommentare auf Deutsch; Logging auf Deutsch
- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
- Flyway ist die einzige Schema-Evolutionsquelle keine Migration in V3.2
---
## Headless-Kompatibilität
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten.
Scheduler-Properties (`scheduler.enabled`, `scheduler.interval.seconds`)
werden im headless-Modus weder gelesen noch validiert. Der headless-Pfad
verwendet keinen Scheduler-Codepfad und keinen Config-Lock.
---
## Datenbank-Migration
**Keine.** Das DB-Schema bleibt unverändert auf V1 (`V1__initial_schema.sql`).
Es wurden keine neuen Spalten und keine neuen Tabellen angelegt.
---
## Produkttest
**Produkttest: bestanden**
Manueller GUI-Produkttest gegen echten KI-Provider mit echten PDFs
abgeschlossen. Der Scheduler hat PDFs automatisch erkannt, per KI benannt
und in den Zielordner verschoben vollautomatisch ohne Nutzeraktion.
Alle wesentlichen Szenarien (Start/Stop, No-op-Lauf, aktive Verarbeitung,
Verlauf-Tab bei aktivem Scheduler, App-Schließen-Guard) wurden verifiziert.
---
## Bekannte Einschränkungen
| Einschränkung | Bewertung |
|---|---|
| JavaFX `NullPointerException` beim Schließen (`GraphicsPipeline.getPipeline() == null`) | JavaFX-interner Fehler nach Shutdown; kein Fehler im Anwendungscode; kein Datenverlust; kein Handlungsbedarf |
| Unvollständige PDFs (noch im Kopiervorgang) können temporär `FAILED_RETRYABLE` erzeugen | Erwartet; bestehende Retry-Semantik behandelt das korrekt beim nächsten Tick |
---
## Nicht in V3.2
- Token- und Kosten-Tracking (#74) → V3.x (eigene Spezifikation mit
Modell-Preistabelle, Persistenz-Strategie, EUR-Währung)
- Headless-Daemon-Betrieb des Schedulers (`--watch`-Flag) → V3.x
- Java WatchService (ereignisgesteuerte Ordnerüberwachung) → V3.x
- Windows-Service-Integration (WinSW o.ä.) → V3.x
- Modell-Filterung (OpenAI-Snapshots ausblenden) → V3.x
- Dark Mode (#70) → V3.x
- F1-Hilfe (#69) → V3.x
- Log-Viewer in der GUI (#72) → V3.x
- Excel-Export (#75) → V3.x
- Automatische Update-Prüfung (#76) → V3.x
- Neue KI-Provider, Architekturbrüche
- Änderung der fachlichen Kernverarbeitung des PDF-Umbenenners
---
## Nächste Version
**V3.x** Token- und Kosten-Tracking als eigenständiges, vollständig
durchdachtes Feature: Modell-Preistabelle (pro Modell, nicht pro Provider),
EUR-Währung, Kostenanzeige im Summary-Banner, Modell-Filterung für
OpenAI-kompatible Provider.
---
## Freigabeaussage
V3.2 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der
hexagonalen Architektur sind eingehalten. Das neue Modul `adapter-in-scheduler`
ist korrekt eingebunden (kein JavaFX, PIT deaktiviert, flatten aktiv).
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert
gegenüber V3.1. Headless-Betrieb vollständig unberührt. Manueller
Produkttest bestanden. Keine Release-Blocker.
+505 -15
View File
@@ -1,4 +1,4 @@
# GUI-Bedienanleitung PDF-Umbenenner V2.0
# GUI-Bedienanleitung PDF-Umbenenner
Diese Anleitung beschreibt die JavaFX-Desktop-GUI des PDF-Umbenenners. Sie richtet sich an
Endbenutzer und Betreuer, die die Konfiguration der Anwendung über die grafische Oberfläche
@@ -8,15 +8,20 @@ verwalten und technisch prüfen möchten.
## 1. Zweck und Scope der GUI
Die GUI gliedert sich in zwei feste Tabs:
Die GUI gliedert sich in fünf feste Tabs:
- **Tab 1 „Konfiguration"** Editor, Validierungsoberfläche und technische
Test-/Diagnoseoberfläche für die `.properties`-Datei.
- **Tab 2 „Verarbeitungslauf"** Start eines Batch-Laufs aus der GUI mit
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
- **Tab 3 „Scheduler"** Optionaler automatischer Scheduler für periodische
Verarbeitungsläufe (siehe Abschnitt 14).
- **Tab 4 „Verlauf"** Ansicht aller bisher verarbeiteten Dokumente mit Status
und Verarbeitungsdetails aus der SQLite-Datenbank (siehe Abschnitt 17).
- **Tab 5 „Prompt"** Editor zum Lesen, Bearbeiten und Speichern der
konfigurierten KI-Prompt-Datei (siehe Abschnitt 18).
Weiterhin **nicht** enthalten sind ein Historien-Tab, eine Datenbankansicht und ein
Kosten-Tracking — diese Ausbauten sind für spätere Stufen vorbehalten.
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 19).
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
`--headless` der empfohlene Weg.
@@ -334,13 +339,23 @@ vorbelegt.
## 8. Dirty-State und Schutzdialoge
### Konfigurations-Tab
Sobald eine geladene oder neu erzeugte Konfiguration bearbeitet wird, gilt der
Editor als „dirty" (ungespeicherte Änderungen). Zwei visuelle Markierungen
Editor als „dirty" (ungespeicherte Änderungen). Drei visuelle Markierungen
zeigen diesen Zustand an:
- Ein **`*`**-Präfix im **Tab-Titel**: `* Konfiguration`
- Ein **`*`**-Präfix im Fenstertitel
- Ein kleines **„geändert"**-Label im Header
Das Dirty-Flag wird über einen **Baseline-Snapshot** ermittelt: Beim Laden einer
Konfiguration wird ein Snapshot des geladenen Zustands gespeichert. Erst wenn
der aktuelle Formularinhalt vom Snapshot abweicht, erscheint der Dirty-Indikator.
Programmgesteuertes Laden und Normalisieren von Feldinhalten lösen keinen
Dirty-State aus. Auch ein DB-Pfad-Wechsel über „Neue Datenbank anlegen..."
(Abschnitt 17a) versetzt den Konfigurations-Tab in den Dirty-State.
Vor den Aktionen „Neu", „Öffnen" und beim Schließen des Fensters prüft die GUI,
ob ungespeicherte Änderungen vorhanden sind. Ist dies der Fall, erscheint ein
Schutzdialog mit drei Optionen:
@@ -351,6 +366,12 @@ Schutzdialog mit drei Optionen:
| **Verwerfen** | Verwirft die Änderungen und führt die Aktion aus |
| **Abbrechen** | Bricht die Aktion ab; die Änderungen bleiben erhalten |
### Prompt-Tab
Der Prompt-Tab zeigt ebenfalls ein Asterisk im Tab-Titel (`Prompt *`), sobald der
TextArea-Inhalt vom gespeicherten Stand abweicht. Das Verhalten ist identisch zum
Konfigurations-Tab (Schutzdialog, Reset nach Speichern).
---
## 9. `.bak`-Sicherung beim Überschreiben und Legacy-Migration
@@ -479,16 +500,20 @@ in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein.
| `✓` | Grün | Erfolgreich |
| `↻` | Orange | Fehlgeschlagen (wiederholbar) |
| `×` | Rot | Fehlgeschlagen (permanent) |
| `≡` | Blau | Übersprungen (bereits erfolgreich verarbeitet) |
| `⊘` | Grau | Übersprungen (endgültig fehlgeschlagen) |
| `≡` | Grau | Übersprungen (bereits erfolgreich verarbeitet) |
| `⊘` | Dunkelgrau | Übersprungen (endgültig fehlgeschlagen) |
| `⟳` | Grau | Zurückgesetzt wartet auf nächsten Lauf |
Farbe ist niemals das einzige Unterscheidungsmerkmal Icon und Tooltip beschreiben
den Status auch ohne Farbwahrnehmung eindeutig. Die vollständige Status-Mapping-Tabelle
mit Tooltips ist in Abschnitt 20 beschrieben.
- Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge
zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des
KI-Reasonings. Liegt weder Reasoning noch Fehlermeldung vor, erscheint der
Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning vor.".
- Nach Laufende erscheint die Zusammenfassung `X erfolgreich, Y fehlgeschlagen,
Z übersprungen` im Meldungs- und Zusammenfassungsbereich.
- 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
@@ -612,10 +637,31 @@ Das Panel enthält drei Bereiche:
- **Seitennavigation:** Über die Schaltflächen **„◀"** und **„▶"** (oder das Mausrad)
kann seitenweise geblättert werden. Die aktuelle Seitenzahl und Gesamtseitenzahl
werden angezeigt.
- **Fit-to-view:** Die Seite wird automatisch an die verfügbare Fläche angepasst
(preserveRatio=true). Keine Scrollbalken, keine manuelle Zoom-Einstellung.
- **Fit-to-Width:** Nach dem Laden wird die Seite automatisch an die verfügbare Breite
angepasst (preserveRatio=true).
- Das Rendering erfolgt direkt über Apache PDFBox bei 120 DPI.
#### Zoom per Mausrad (Strg+Mausrad)
- **Strg + Mausrad nach oben/unten** zoomt die Vorschau herein bzw. heraus.
- Zoombereich: **10 % bis 500 %**, ca. 10 % je Mausrad-Rastpunkt.
- Nach dem ersten manuellen Zoom verlässt die Vorschau den Fit-to-Width-Modus.
Fit-to-Width wird erst wieder aktiv, wenn ein neues PDF geladen oder der
Fit-to-Width-Button explizit betätigt wird.
- Beim Laden eines neuen PDF wird der Zoom auf Fit-to-Width zurückgesetzt.
- Beim Zoomen bleibt die sichtbare Viewport-Mitte möglichst stabil.
- Trackpad-Gesten (sehr kleine Delta-Werte) werden intern akkumuliert, bis ein
vollständiger Zoomschritt erreicht ist.
- **Ohne Strg:** Mausrad scrollt die Seite normal (kein Zoom).
- ScrollEvents mit gedrückter Strg-Taste werden immer konsumiert, sodass kein
paralleles Scrollen im Hintergrund stattfindet.
#### Grab & Pan (Handcursor im Zoom-Modus)
Im vergrößerten Zustand (Zoom über Fit-to-Width) wechselt der Mauszeiger über
der Vorschau auf einen **Handcursor**. Durch Klicken und Ziehen (Drag) kann die
Ansicht verschoben werden. Im Fit-to-Width-Modus ist Pan nicht aktiv.
### KI-Begründung und Fehlertext
Der mittlere Bereich zeigt das KI-Reasoning des ausgewählten Eintrags.
@@ -666,21 +712,139 @@ Das Feld kann direkt bearbeitet werden. Die Eingabe wird **live validiert**
---
## 14. Bekannte Einschränkungen V2.x
## 13c. Summary-Banner nach Laufabschluss
Nach Abschluss eines Verarbeitungslaufs erscheint unterhalb des Fortschrittsbalkens und
oberhalb der Ergebnistabelle ein einzeiliges **Summary-Banner** (`BatchRunSummaryBanner`).
Es zeigt auf einen Blick, wie viele Dateien in welche Kategorie gefallen sind.
**Beispiel:**
```
✓ 14 erfolgreich · ↻ 1 wird wiederholt · × 2 fehlgeschlagen · ≡ 3 übersprungen · ⊘ 1 endgültig übersprungen
```
**Regeln:**
- Nur Kategorien mit Anzahl größer als 0 werden angezeigt.
- Bei einem vollständig sauberen Lauf erscheint nur die Erfolgskategorie,
z. B. `✓ 17 erfolgreich`.
- Jedes Segment enthält Icon und Text Farbe ist niemals das einzige Unterscheidungsmerkmal.
- Das Banner verschwindet automatisch, wenn der nächste Lauf gestartet wird.
- Das Banner erscheint **nicht** beim Anwendungsstart oder bei einem Tab-Wechsel
ohne vorherigen Lauf.
**Kategorien:**
| Icon | Text | Entsprechender Status |
|------|------|-----------------------|
| `✓` | erfolgreich | `SUCCESS` |
| `↻` | wird wiederholt | `FAILED_RETRYABLE` |
| `×` | fehlgeschlagen | `FAILED_FINAL` |
| `≡` | übersprungen | `SKIPPED_ALREADY_PROCESSED` |
| `⊘` | endgültig übersprungen | `SKIPPED_FINAL_FAILURE` |
Die Zwischenstatus `READY_FOR_AI`, `PROPOSAL_READY` und `PROCESSING` werden im Banner
nicht gezählt sie treten nach Laufabschluss nicht mehr auf.
---
## 14. Tab „Scheduler" (automatische Verarbeitungsläufe)
Der dritte Tab **„Scheduler"** ermöglicht den Betrieb eines optionalen, periodisch
ausgeführten automatischen Schedulers. Er startet Verarbeitungsläufe in einem
konfigurierten Intervall, ohne dass ein manueller Start erforderlich ist.
### Voraussetzung
Damit der Scheduler-Tab funktioniert, muss in der **gespeicherten** Konfigurationsdatei
`scheduler.enabled=true` und ein gültiges `scheduler.interval.seconds` (Integer >= 30)
eingetragen sein. Ungültige oder fehlende Werte werden im Tab als Fehler gemeldet; der
Scheduler-Start ist in diesem Fall nicht möglich.
### Start und Stop
- **„Scheduler starten"** Aktiviert den Scheduler. Der erste Lauf beginnt
**unmittelbar** nach dem Start (kein initiales Warten auf das Intervall).
- **„Scheduler stoppen"** Stoppt den Scheduler. Ein laufender Verarbeitungslauf wird
als Soft-Stop behandelt: die aktuell bearbeitete Datei wird fertig verarbeitet,
danach hält der Scheduler an.
Beide Buttons wechseln je nach Zustand ihre Sichtbarkeit: Nur der zum aktuellen
Zustand passende Button ist aktiv.
### Statusanzeige
Der Tab zeigt den aktuellen Scheduler-Zustand in Echtzeit (1-Sekunden-Takt):
| Zustand | Anzeige |
|---------|---------|
| `STOPPED` | Scheduler gestoppt |
| `STARTING` | Scheduler wird gestartet … |
| `RUNNING_IDLE` | Scheduler läuft nächster Lauf in `HH:MM:SS` |
| `RUNNING_BATCH_ACTIVE` | Scheduler läuft Verarbeitungslauf aktiv |
| `STOPPING_BATCH_ACTIVE` | Scheduler wird gestoppt Lauf läuft noch … |
Im Zustand `RUNNING_IDLE` zeigt der Tab einen Countdown bis zum nächsten automatischen
Verarbeitungslauf.
### Informationen zum letzten Lauf
Der Tab zeigt:
- **Letzter Lauf beendet:** Zeitpunkt des letzten abgeschlossenen Verarbeitungslaufs
(oder „–" wenn noch kein Lauf stattfand).
- **Zusammenfassung:** Anzahl erfolgreich, wiederholt, fehlgeschlagen und übersprungen
des letzten Laufs (falls verfügbar).
- **Letzter Fehler:** Fehlermeldung des letzten nicht erfolgreichen Scheduler-Laufs,
sofern vorhanden.
### Autostart-Fehler
Ist `scheduler.enabled=true` in der Konfiguration, versucht die GUI den Scheduler
beim Start automatisch zu aktivieren. Schlägt dies fehl (z. B. ungültige Konfiguration,
Intervall < 30 Sekunden), wird der Fehler im Tab angezeigt. Der Benutzer kann dann die
Konfiguration korrigieren und den Scheduler manuell starten.
### Warum sind manuelle Läufe während eines aktiven Schedulers gesperrt?
Manuelle Läufe (Tab „Verarbeitungslauf") sind während eines aktiven Schedulers
deaktiviert. Dadurch werden parallele Läufe auf dieselbe Datenmenge vermieden, die
zu inkonsistenten Datenbankzuständen führen könnten. Der Start-Button im Tab
„Verarbeitungslauf" ist während eines aktiven Schedulers deaktiviert und zeigt einen
erklärenden Tooltip.
### Warum ist Tab 1 „Konfiguration" während eines aktiven Schedulers gesperrt?
Um sicherzustellen, dass der Scheduler mit einer konsistenten Konfiguration läuft,
ist der Konfigurations-Editor während eines aktiven Schedulers gesperrt. Ein
Hinweisbanner erklärt die Sperre. Konfigurationsänderungen können nach dem Stoppen
des Schedulers vorgenommen werden.
### Schließen der Anwendung
Versucht der Benutzer das Fenster zu schließen oder die Anwendung über das
Tray-Menü zu beenden, während der Scheduler aktiv ist oder ein Lauf läuft, erscheint
ein Informationsdialog mit dem Hinweis, den Scheduler zunächst zu stoppen bzw. den
laufenden Verarbeitungslauf abzuwarten. Das Schließen wird blockiert, bis der Scheduler
gestoppt und kein Lauf mehr aktiv ist.
---
## 15. Bekannte Einschränkungen
| Einschränkung | Erläuterung |
|---|---|
| Kein Historien-Tab | Eine Ansicht der SQLite-Datenbank und Verarbeitungshistorie ist für spätere Ausbaustufen vorbehalten |
| Kein Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten |
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer |
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. Die dauerhaften Ergebnisse sind im Verlauf-Tab (Abschnitt 17) einsehbar. |
| Einzelinstanz-Schutz | Wird die Anwendung ein zweites Mal gestartet, während bereits eine Instanz läuft (auch wenn diese im System-Tray minimiert ist), beendet sich die neue Instanz sofort ohne Hinweisfenster |
| Prompt-Editor: kein automatisches Reload | Wird die Prompt-Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht. Beim Speichern gilt Last-write-wins. |
---
## 15. System-Tray
## 16. System-Tray
Wird das Hauptfenster über das Schließen-Symbol (oder Alt+F4) geschlossen, ohne dass
ungespeicherte Änderungen oder ein aktiver Verarbeitungslauf vorliegen, **minimiert
@@ -705,3 +869,329 @@ Ein **Doppelklick** auf das Tray-Icon hat denselben Effekt wie „Öffnen".
| Ungespeicherte Änderungen | Schutzdialog erscheint zuerst (Speichern / Verwerfen / Abbrechen); erst nach Auflösung wird in den Tray minimiert |
| Aktiver Verarbeitungslauf | Hinweisdialog erscheint (Abschnitt 13); nach Soft-Stop oder Abschluss kann in den Tray minimiert werden |
| System-Tray nicht verfügbar | Fenster wird beim Schließen wie ohne Tray-Support behandelt; der Schutzdialog für ungespeicherte Änderungen bleibt aktiv |
---
## 17. Tab „Verlauf" (Historien-Tab)
Der dritte Tab **„Verlauf"** zeigt alle jemals verarbeiteten Dokumente mit Status,
Dateinamen und Verarbeitungsdetails. Die Daten stammen direkt aus der SQLite-Datenbank,
die in der geladenen Konfiguration angegeben ist.
### Layout
Das Tab ist zweigeteilt:
- **Linke Hälfte (~55%):** Dokumentenliste mit Filter-Bereich oben
- **Rechte Hälfte (~45%):** Detailbereich zum ausgewählten Dokument
### Dokumentenliste
Die Tabelle zeigt folgende Spalten:
| Spalte | Inhalt |
|--------|--------|
| Status-Icon | Symbol und Farbe gemäß Status-Mapping-Tabelle (Abschnitt 20) |
| Quelldateiname | Ursprünglicher Dateiname der PDF-Datei |
| Zieldateiname | Zuletzt vergebener Dateiname nach Umbenennung |
| Quellpfad | Letzter bekannter Quellordner |
| Letzter Versuch | Zeitpunkt der letzten Statusänderung |
| Anzahl Versuche | Gesamtzahl aller Verarbeitungsversuche |
**Sortierung:** Standardmäßig absteigend nach dem letzten Versuch (neueste zuerst).
**Hinweise zur Anzeige:**
- Lange Dateinamen und Pfade werden in der Tabelle abgekürzt (Ellipsis). Der vollständige
Text erscheint im Tooltip beim Hover.
- Bei mehr als 500 Treffern erscheint der Hinweis „Weitere Einträge vorhanden Filter
verwenden". Es werden dann nur die 500 neuesten Einträge angezeigt.
- Bei leerer Datenbank erscheint der Text „Noch keine Verarbeitungen vorhanden."
### Filter
Über dem Tab befinden sich drei Bedienelemente:
- **Freitextsuche** filtert über Quelldateiname und Zieldateiname, case-insensitiv
- **Status-Filter** ComboBox zur Auswahl eines bestimmten Status oder „Alle Status"
- **„Suchen"** startet die Suche sofort; alternativ die Enter-Taste im Suchfeld
Die Suche erfolgt datenbanksseitig; Sonderzeichen in der Sucheingabe werden korrekt behandelt.
#### Live-Suche
Die Freitextsuche reagiert **live** auf Tastatureingaben: 300 ms nach dem letzten
Tastendruck startet die Suche automatisch auf einem Hintergrund-Thread.
Der Such-Button und die Enter-Taste starten die Suche sofort ohne Verzögerung.
Nach jeder neuen Suchanfrage wird die Tabellenauswahl vollständig geleert;
Detailbereich und Aktionsbuttons werden zurückgesetzt. Ein leeres Suchfeld zeigt
alle Einträge (bis Limit 500).
### Mehrfachauswahl
Die Verlauf-Tabelle unterstützt **Mehrfachauswahl**:
| Geste | Wirkung |
|---|---|
| **Klick** | Einzelauswahl |
| **Strg+Klick** | Einzelnen Eintrag zur Auswahl hinzufügen oder entfernen |
| **Shift+Klick** | Bereich vom letzten zur aktuellen Zeile auswählen |
| **Strg+A** | Alle sichtbaren Einträge auswählen (**nur wenn die Tabelle den Fokus hat**) |
> **Hinweis:** Liegt der Fokus im Suchfeld, wirkt Strg+A als normale Textselektion
> im Suchfeld und selektiert keine Tabellenzeilen.
Bei Mehrfachauswahl zeigt der **Detailbereich** den Platzhaltertext
„X Einträge ausgewählt." (statt Dokumentdetails).
### Detailbereich
Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
**Dokument-Info:**
- Fingerprint (12 Zeichen des SHA-256-Hash)
- Quelldateiname und Quellpfad
- Status (Icon + Text)
- Erstellt am / Aktualisiert am
**Versuche-Tabelle:** Alle bisher protokollierten Verarbeitungsversuche:
| Spalte | Inhalt |
|--------|--------|
| # | Versuchsnummer |
| Datum | Endzeitpunkt des Versuchs |
| Status | Ergebnisstatus des Versuchs (lesbarer Anzeigetext, kein Enum-Rohname) |
| Provider | Verwendeter KI-Provider |
| Modell | Verwendetes Sprachmodell |
| Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname |
**KI-Begründung / Fehlerursache:**
Das `ai_reasoning` des zuletzt ausgewählten Versuchs als nicht editierbarer Text.
Ist kein Reasoning gespeichert, erscheint ein gedimmter Platzhaltertext
„Keine KI-Begründung für diesen Versuch gespeichert."
Bei Einträgen mit Status `FAILED_FINAL`, `FAILED_RETRYABLE` oder
`SKIPPED_FINAL_FAILURE` wird zusätzlich die **Fehlerursache** des letzten
fehlgeschlagenen Versuchs angezeigt. Liegt keine Fehlerursache vor (z. B. ältere
Einträge), erscheint ebenfalls ein Platzhaltertext.
### Aktionen
Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung.
**Beide Aktionen unterstützen Mehrfachauswahl** (≥ 1 Eintrag):
**„Status zurücksetzen"**
Setzt den Status der ausgewählten Dokumente auf „Wartet auf Verarbeitung" zurück,
sodass sie beim nächsten Verarbeitungslauf automatisch erneut verarbeitet werden.
Die Versuchshistorie bleibt vollständig erhalten kein Versuch wird gelöscht.
Vor der Aktion erscheint ein Bestätigungsdialog: „X Einträge zurücksetzen?"
Bei Mehrfachauswahl werden Einträge einzeln zurückgesetzt. Nach Abschluss erscheint
eine kompakte Zusammenfassung „X von Y erfolgreich verarbeitet." Detaillierte
Einzelfehler werden geloggt.
Wann sinnvoll: wenn die Ursache eines Fehlers behoben wurde (z. B. OCR nachträglich
durchgeführt, Passwortschutz entfernt) und das Dokument erneut verarbeitet werden soll.
**„Eintrag löschen"**
Löscht die Stammsätze und alle Verarbeitungsversuche der ausgewählten Dokumente
vollständig aus der Datenbank. Diese Aktion ist **nicht rückgängig zu machen**.
Vor der Aktion erscheint ein Bestätigungsdialog: „X Einträge unwiderruflich löschen?"
Bei Mehrfachauswahl gilt dieselbe Partial-Success-Logik wie beim Zurücksetzen.
**Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert.
Nach Laufende werden die Buttons automatisch reaktiviert, sofern eine Auswahl besteht
ohne dass der Benutzer die Auswahl erneuern muss.
---
## 17a. Neue Datenbank anlegen
Über **Datenbank → Neue Datenbank anlegen...** in der Menüleiste kann eine neue,
leere SQLite-Datenbank erstellt und sofort als aktive Datenbank der Anwendung
gesetzt werden ohne Neustart.
### Voraussetzung
Der Menüpunkt ist nur aktiv, wenn kein Verarbeitungslauf läuft.
### Ablauf
1. Ein Dateidialog öffnet sich (Filter: `*.sqlite` und `*.db`). Neue Zieldatei
wählen oder eingeben.
2. Die Anwendung prüft, ob die gewählte Datei identisch mit der aktuell aktiven
Datenbank ist (normalisierter, case-insensitiver Pfadvergleich). Bei
Übereinstimmung erscheint eine Fehlermeldung, kein Überschreiben.
3. Existiert die gewählte Datei bereits (andere als aktive DB): Bestätigungsdialog
„Die Datei existiert bereits. Überschreiben?"
4. Die neue DB wird als temporäre Datei im Zielverzeichnis erzeugt. Flyway
führt alle Migrationsskripte auf den neuesten Schema-Stand aus.
5. Verbindungstest: Verbindung öffnen, Flyway-History prüfen, Leseabfrage prüfen.
6. Nach erfolgreichem Test: atomarer Move zur Zieldatei
(`ATOMIC_MOVE + REPLACE_EXISTING`). Schlägt dies fehl, bricht der Vorgang
mit einer klaren Fehlermeldung ab.
7. Die aktive Datenbankverbindung wechselt zur neuen DB.
8. Der Verlauf-Tab lädt neu: „Noch keine Verarbeitungen vorhanden."
9. Die Statuszeile aktualisiert den DB-Pfad.
10. Die Konfiguration wird als geändert markiert (Dirty-State im Konfig-Tab).
11. Im Meldungsbereich erscheint der Hinweis:
„Neue Datenbank ist aktiv. Konfiguration speichern, damit diese Datenbank
beim nächsten Start verwendet wird."
### Fehlerfall
Schlägt ein Schritt fehl, bleibt die bisherige Datenbank vollständig unverändert
in Betrieb. Die temporäre Datei wird gelöscht. Ein Fehlerdialog erscheint mit
einer konkreten Meldung.
### Wichtiger Hinweis
**Die Konfigurationsdatei wird durch den DB-Wechsel nicht automatisch gespeichert.**
Damit die neue Datenbank beim nächsten Start der Anwendung verwendet wird, muss
die Konfiguration explizit über „Speichern" oder „Speichern unter" gesichert werden.
Der Dirty-State im Konfig-Tab und der Hinweis im Meldungsbereich erinnern daran.
---
## 18. Tab „Prompt" (Prompt-Editor)
Der vierte Tab **„Prompt"** ermöglicht das Lesen, Bearbeiten und Speichern der
KI-Prompt-Datei direkt in der GUI ohne externen Editor.
### Inhalt und Bedienung
Die TextArea zeigt den aktuellen Inhalt der in der Konfiguration eingetragenen
Prompt-Datei. Der Inhalt ist vollständig editierbar.
**Buttons:**
- **„Speichern"** schreibt den aktuellen Inhalt atomar in die Prompt-Datei
(Temp-Datei im selben Verzeichnis, dann atomarer Austausch). Encoding: UTF-8;
Zeilenenden werden unverändert übernommen. Bei einem Fehler erscheint eine
Fehlermeldung im Tab; es gibt keinen stillen Fallback.
- **„Auf Standard zurücksetzen"** füllt die TextArea mit dem eingebauten
Standard-Template, ohne die Datei sofort zu speichern. Erst ein anschließendes
„Speichern" schreibt die Änderung auf den Datenträger.
**Dirty State:**
Sobald der TextArea-Inhalt vom gespeicherten Stand abweicht, erscheint ein
Asterisk im Tab-Titel: **„Prompt \*"**. Wird der Tab gewechselt oder die
Anwendung geschlossen, während ungespeicherte Änderungen vorliegen, erscheint
ein Bestätigungsdialog mit der Frage „Änderungen verwerfen?".
### Fehlende Prompt-Datei
Ist keine Prompt-Datei konfiguriert oder existiert die konfigurierte Datei nicht,
zeigt der Tab einen Hinweistext und den Button **„Standard-Prompt erstellen"**.
Ein Klick legt eine Prompt-Datei mit dem deutschen Standard-Template an
(standardmäßig im selben Ordner wie die geladene `.properties`-Datei).
### Hinweise
- Das Tab lädt den Dateiinhalt automatisch, wenn es geöffnet wird (Hintergrund-Thread).
- Wird die Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht.
Beim Speichern gilt Last-write-wins.
- Für den Betrieb über MSI oder Task Scheduler wird empfohlen, den Prompt-Pfad
in der Konfiguration als absoluten Pfad anzugeben, um vom jeweiligen Arbeitsverzeichnis
unabhängig zu sein.
---
## 19. Statuszeile
Am unteren Rand des Hauptfensters ist permanent eine **Statuszeile** (`GuiStatusBar`)
sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
| Position | Inhalt | Beispiel |
|----------|--------|---------|
| Links | Anwendungsversion | `V3.0.42` |
| Mitte | Aktiver Provider und Modell | `Provider: Claude · claude-opus-4-7` |
| Rechts | Pfad der geladenen Konfigurationsdatei | `config/application.properties` |
**Besonderheiten:**
- Die Versionsangabe wird aus der JAR-Manifest-Datei gelesen. Beim Start aus einer IDE
ohne gepacktes JAR erscheint der Fallback `Vdev`.
- Ist keine Konfiguration geladen, zeigen Mitte und Rechts den Text „Kein Profil geladen".
- Die Statuszeile aktualisiert sich automatisch beim Laden, Speichern und Schließen
einer Konfigurationsdatei.
---
## 20. Fehlerstatus Bedeutung und Unterscheidung
Zwei Fehlerstatus werden in der GUI klar unterschieden. Die Unterscheidung ist wichtig,
um zu entscheiden, ob eine erneute Verarbeitung sinnvoll ist.
### `↻` Wird wiederholt (orange) `FAILED_RETRYABLE`
Das Dokument konnte vorübergehend nicht verarbeitet werden. Der Fehler ist
wahrscheinlich technischer Natur und kann sich bei einem späteren Versuch
von selbst auflösen.
**Typische Ursachen:** Netzwerkfehler, Timeout beim KI-Dienst, vorübergehende
Nicht-Erreichbarkeit.
**Was passiert:** Das Dokument wird beim nächsten regulären Verarbeitungslauf
**automatisch erneut versucht** kein manuelles Eingreifen notwendig.
### `×` Fehlgeschlagen (rot) `FAILED_FINAL`
Das Dokument ist dauerhaft nicht verarbeitbar. Automatische Wiederholversuche
werden nicht mehr unternommen.
**Typische Ursachen:**
- Kein lesbarer Text (z. B. eingescanntes Foto ohne OCR-Verarbeitung)
- Passwortgeschützte PDF
- Beschädigte oder unlesbare Datei
**Was passiert:** Das Dokument wird in späteren Läufen übersprungen.
**Mögliche Abhilfe:** Wenn die Ursache behoben wurde (z. B. OCR wurde nachträglich
durchgeführt), kann der Status im **Verlauf-Tab** (Abschnitt 17) manuell zurückgesetzt
werden. Das Dokument wird dann beim nächsten Lauf erneut verarbeitet. Alternativ kann
der Eintrag vollständig gelöscht werden, damit die Datei als neu erkannt wird.
---
### Vollständige Status-Mapping-Tabelle
| Status | Icon | Farbe | Tooltip-Text | Summary-Kategorie |
|--------|------|-------|-------------|-------------------|
| `SUCCESS` | `✓` | Grün | „Erfolgreich verarbeitet und umbenannt." | erfolgreich |
| `FAILED_RETRYABLE` | `↻` | Orange | „Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht." | wird wiederholt |
| `FAILED_FINAL` | `×` | Rot | „Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch." | fehlgeschlagen |
| `SKIPPED_ALREADY_PROCESSED` | `≡` | Grau | „Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet." | übersprungen |
| `SKIPPED_FINAL_FAILURE` | `⊘` | Dunkelgrau | „Endgültig übersprungen nach wiederholten Fehlern." | endgültig übersprungen |
| `READY_FOR_AI` | `⟳` | Blau | „Wartet auf Verarbeitung." | |
| `PROPOSAL_READY` | `◇` | Hellblau | „KI-Vorschlag liegt vor, wartet auf Bestätigung." | |
| `PROCESSING` | `▶` | Hellgrau | „Wird gerade verarbeitet." | |
**Wichtig:** Farbe ist niemals das einzige Unterscheidungsmerkmal. Icon und Tooltip-Text
beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
---
## 21. Tooltips
Auf den meisten interaktiven Elementen der GUI sind Tooltips gesetzt, die beim Hover über
ein Element erscheinen. Sie erklären kurz den Zweck des Elements.
Tooltips sind unter anderem vorhanden auf:
- **Konfigurationsfeldern** Quellordner, Zielordner, SQLite-Datei, Prompt-Datei,
Provider-ComboBox, Modell-Feld, `max.text.characters`, `max.pages`, `max.title.length`
- **Toolbar-Buttons** Neu, Öffnen, Speichern, Speichern unter, Validieren,
Technische Tests ausführen
- **Status-Icons** im Verarbeitungslauf-Tab Text gemäß Status-Mapping-Tabelle
(Abschnitt 20)
- **Buttons „Dateiname übernehmen"** und **„Zurücksetzen auf KI-Vorschlag"** im
Dateiname-Editor (Abschnitt 13b)
Der Tooltip erscheint nach einer kurzen Verzögerung beim Verweilen mit der Maus
über dem jeweiligen Element.
+924
View File
@@ -0,0 +1,924 @@
# V3.1 UX-Polish und Verlauf-Tab-Reife
**Status:** Zur Implementierung freigegeben
**Erstellt:** 2026-05-05
**Überarbeitet:** 2026-05-05 (nach ChatGPT-Review Runden 1, 2 und 3)
**Autor:** Marcus (mit Claude als Mentor)
---
## Ziel
V3.1 ist der konsequente Nachschlag zu V3.0: Was der Produkttest aufgedeckt hat,
wird hier bereinigt. Kein großes Architektur-Feature, kein neues Maven-Modul
**gezielter UX-Schliff und Robustheit**.
Schwerpunkte:
1. **Polieren** sichtbare Schwächen aus dem V3.0-Produkttest beheben
(#77, #80, #81, #83, #84, #88, #91)
2. **Verlauf-Tab reifen lassen** Suche, Mehrfachauswahl, DB-Neuanlage
(#82, #86, #87)
3. **Quick Win** Mausrad-Zoom im PDF-Viewer als kleiner,
wertvoller Gebrauchskomfort (#32)
Die fachliche Kernverarbeitung bleibt vollständig unverändert.
---
## Einordnung
V3.0 ist der abgeschlossene Ausgangspunkt. Hexagonale Architektur,
Modulstruktur, headless-Betrieb, `.properties`-Konfigurationswahrheit
und Flyway-DB-Evolution bleiben unangetastet.
V3.1 fügt **kein neues Maven-Modul** hinzu.
**Headless-Betrieb:** Der `adapter-in-cli`-Pfad erhält keine neue Bedienfunktion.
Er ist jedoch von der globalen Lock-File-Pfadauflösung (#91) und einer
ggf. notwendigen Flyway-Schemamigration (#88) betroffen beide Änderungen
wirken beim Programmstart, unabhängig von GUI oder CLI.
---
## Scope
### In V3.1 enthalten
| # | Thema | Kategorie |
|---|---|---|
| #77 | Fehlende Tooltips | UX |
| #80 | Dirty-Indikator für Konfigurations-Tab | UX |
| #81 | Enum-Werte statt deutscher Bezeichnungen (Status-ComboBox + Versuche-Tabelle) | UX |
| #82 | Verlauf-Tab: Live-Filter bei Suche | GUI |
| #83 | KI-Begründung bei SUCCESS-Versuch verwirrend leer | UX |
| #84 | Aktionsbuttons nach Laufende nicht sofort reaktiviert | Bug |
| #86 | Mehrfachauswahl im Verlauf-Tab (Strg+A, Strg+Klick, Shift+Klick) | GUI |
| #87 | Neue leere SQLite-Datenbank anlegen | GUI |
| #88 | FAILED_FINAL-Einträge zeigen keine Fehlerursache im Verlauf-Tab | UX |
| #91 | Lock-File relativer Pfad Fallback wie Log-Verzeichnis | Robustheit |
| #32 | Mausrad-Zoom in PDF-Vorschau | GUI |
### Explizit nicht in V3.1
- Automatischer Scheduler / Quellordner-Überwachung (#22) → V3.x
- PDF-Viewer Render-DPI (#23) → V3.2
- F1-Hilfe (#69) → V3.2
- Dark Mode (#70) → V3.x
- Log-Viewer in der GUI (#72) → V3.2
- Token- und Kosten-Tracking (#74) → V3.2
- Excel-Export (#75) → V3.2
- Automatische Update-Prüfung (#76) → V3.2
- Änderung der fachlichen Kernverarbeitung
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
---
## Unverrückbare Leitplanken (unverändert gegenüber V3.0)
- Java 21, Maven Multi-Module, hexagonale Architektur
- Shade-JAR als primäres Distributionsartefakt
- GUI ist Standardstart, `--headless` bleibt vollständig erhalten
- `.properties` bleibt die einzige Konfigurationswahrheit
- Kein Webserver, kein Applikationsserver
- GUI offiziell nur unter Windows; headless für Windows Server / Task Scheduler
- JavaFX-Threading: I/O auf Worker-Thread, UI-Updates via `Platform.runLater()`
- Kein JavaFX in Domain oder Application
- JavaDoc-Standard für alle neuen öffentlichen Ports, Use-Cases, DTOs und Adapter-Methoden
- Notwendige Code-Kommentare auf Deutsch; Logging auf Deutsch
- Flyway ist die einzige Schema-Evolutionsquelle (kein manuelles DDL im Code)
---
## Status-Mapping-Tabelle (unverändert gegenüber V3.0)
Diese Tabelle ist weiterhin die einzige autoritative Quelle für Status-Darstellung
in der GUI. Sie gilt verbindlich für alle V3.1-Features, die Statuswerte anzeigen
insbesondere #81 (Status-ComboBox, Versuche-Tabelle).
**Alle acht Statuswerte müssen vollständig unterstützt werden.**
Kein Enum-Rohname darf für Endnutzer sichtbar sein.
| Domain-Status (`ProcessingStatus`) | GUI-Icon | Farbe | GUI-Text (Tooltip) | Summary-Kategorie |
|---|---|---|---|---|
| `SUCCESS` | `✓` | Grün | „Erfolgreich verarbeitet und umbenannt." | erfolgreich |
| `FAILED_RETRYABLE` | `↻` | Orange | „Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht." | wird wiederholt |
| `FAILED_FINAL` | `×` | Rot | „Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch." | fehlgeschlagen |
| `SKIPPED_ALREADY_PROCESSED` | `≡` | Grau | „Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet." | übersprungen |
| `SKIPPED_FINAL_FAILURE` | `⊘` | Dunkelgrau | „Endgültig übersprungen nach wiederholten Fehlern." | endgültig übersprungen |
| `READY_FOR_AI` | `⟳` | Blau | „Wartet auf Verarbeitung." | |
| `PROPOSAL_READY` | `◇` | Hellblau | „KI-Vorschlag liegt vor, wartet auf Bestätigung." | |
| `PROCESSING` | `▶` | Hellgrau | „Wird gerade verarbeitet." | |
**Wichtig:** Farbe ist niemals das einzige Unterscheidungsmerkmal.
Icon und Tooltip-Text müssen den Status allein eindeutig beschreiben.
---
## UX-Polishing-Features
### #77 Fehlende Tooltips
#### Problem
Der V3.0-Produkttest hat GUI-Elemente identifiziert, die noch keinen Tooltip
tragen. Die Infrastruktur (`GuiTooltipTexts`, `setTooltip()`) existiert bereits
aus #66 es fehlt nur die konsequente Anwendung.
#### Lösung
Vor der Implementierung führt Claude Code eine **vollständige Bestandsaufnahme**
durch: Alle interaktiven Elemente auf allen Tabs werden gegen vorhandene Tooltips
geprüft. Maßgeblich ist die Bestandsaufnahme die Zahl 16 stammt aus dem
Produkttest und ist nicht bindend. Werden mehr fehlende Elemente gefunden,
werden alle ergänzt.
Fehlende Tooltips werden in `GuiTooltipTexts` als Konstanten ergänzt und
im jeweiligen GUI-Tab via `element.setTooltip(new Tooltip(GuiTooltipTexts.XY))`
gesetzt. Keine hartcodierten Strings.
**Tooltips auf `TableColumn`-Headern (Sonderfall JavaFX):**
`TableColumn` ist kein normaler JavaFX-Node; `setTooltip()` ist darauf nicht
direkt anwendbar. **Kein Skin-/Lookup-Hack.** Falls Header-Tooltips benötigt
werden, wird ein `Label` als Column-Graphic gesetzt:
```java
Label headerLabel = new Label("Spaltenname");
headerLabel.setTooltip(new Tooltip("Erklärungstext"));
column.setGraphic(headerLabel);
column.setText("");
```
Bei der Umsetzung muss geprüft werden, dass Sortierung, Header-Breite
und bestehendes CSS durch das Column-Graphic-Pattern nicht sichtbar
verschlechtert werden.
Falls das Projekt bereits eine stabile eigene Lösung für Column-Tooltips
besitzt, wird diese wiederverwendet.
**Zu prüfende Tabs und Elemente (Anhaltspunkte):**
| Tab | Verdächtige Elemente |
|---|---|
| Verlauf | Tabellenspalten-Header, Suchfeld, Such-Button, Aktions-Buttons (Reset, Löschen) |
| Verlauf (Detail) | Status-Icon, Versuche-Tabelle Spalten, KI-Begründung-Bereich |
| Prompt | Speichern-Button, Zurücksetzen-Button, TextArea |
| Allgemein | Fortschrittsbalken, Summary-Banner-Elemente |
**Technisch:** Ausschließlich `adapter-in-gui` und `GuiTooltipTexts`.
Keine Architektur-Änderungen.
---
### #80 Dirty-Indikator für Konfigurations-Tab
#### Problem
Der Prompt-Tab zeigt bereits einen `*`-Dirty-Indikator im Tab-Titel und warnt
beim Verlassen mit ungespeicherten Änderungen. Der Konfigurations-Tab hat dieses
Verhalten nicht Nutzer verlieren versehentlich Änderungen.
#### Lösung
**Dirty-State-Tracking mit Baseline-Snapshot:**
Beim Laden einer Konfiguration wird ein **Baseline-Snapshot** des geladenen Zustands
gespeichert. Dirty-State entsteht durch Vergleich des aktuellen Formularinhalts
mit dem Snapshot nicht durch blindes „erster Listener feuert".
Während programmgesteuertem Laden oder Normalisieren von Feldinhalten wird
Dirty-Tracking temporär unterdrückt (Flag `loadingInProgress`), damit
programmatische Feldänderungen keinen unechten Dirty-State auslösen.
- Beim ersten echten Nutzerwechsel gegenüber dem Snapshot: Tab-Titel wechselt
auf `* Konfiguration`
- Dirty-Flag wird zurückgesetzt bei: Speichern, Speichern unter,
Laden einer neuen Konfiguration (nach Bestätigungsdialog)
**Bestätigungsdialog bei Navigation mit Dirty State:**
Beim Laden einer neuen Konfiguration oder beim Schließen der Anwendung
mit ungespeicherten Konfig-Änderungen:
> „Die Konfiguration enthält ungespeicherte Änderungen. Jetzt speichern?"
> [Speichern] [Verwerfen] [Abbrechen]
**Kopplung mit #87 (Neue Datenbank):**
Legt der Nutzer über „Neue Datenbank anlegen..." eine neue DB-Datei an,
wird der DB-Pfad im Konfigurationsmodell geändert und der Konfig-Tab
in den Dirty-State versetzt. Der bestehende Bestätigungsdialog greift
beim nächsten Schließen oder Ladevorgang.
**UX-Konsistenz mit Prompt-Tab:**
Die UX muss identisch zum Prompt-Tab sein: Sternchen im Tab-Titel,
Warn-/Speicherdialog beim Verlassen, Rücksetzen nach Speichern.
Die **technische Umsetzung** darf im Konfig-Tab über Baseline-Snapshot
und `loadingInProgress` erfolgen, wenn die komplexere Formularlogik
das erfordert.
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
---
### #81 Enum-Werte statt deutscher Bezeichnungen
#### Problem
Die Status-ComboBox im Verlauf-Tab zeigt rohe Enum-Namen (`READY_FOR_AI`,
`FAILED_FINAL` etc.). Die Versuche-Tabelle im Detailbereich zeigt ebenfalls
Enum-Rohnamen in der Status-Spalte. Das ist für Endnutzer unlesbar.
#### Lösung
**Anzeige-Mapping:**
`ProcessingStatusPresentation` (existiert bereits aus #51) stellt die Mapping-Logik
bereit. Dieses Mapping wird für alle Statusanzeigen im Verlauf-Tab verbindlich genutzt.
**Alle acht Statuswerte der autoritativen Tabelle müssen abgedeckt sein:**
| Enum-Wert | Angezeigter Text |
|---|---|
| `SUCCESS` | „✓ Erfolgreich" |
| `FAILED_RETRYABLE` | „↻ Temporärer Fehler" |
| `FAILED_FINAL` | „× Dauerhaft fehlgeschlagen" |
| `SKIPPED_ALREADY_PROCESSED` | „≡ Bereits verarbeitet" |
| `SKIPPED_FINAL_FAILURE` | „⊘ Endgültig übersprungen" |
| `READY_FOR_AI` | „⟳ Wartet auf Verarbeitung" |
| `PROPOSAL_READY` | „◇ Vorschlag vorhanden" |
| `PROCESSING` | „▶ In Bearbeitung" |
**Status-ComboBox:**
- Erster Eintrag: „Alle Status" GUI-intern als `Optional.empty()` bzw. `null`-Filter
behandelt; kein Domain-Enum-Wert
- Weitere Einträge: alle acht Statuswerte mit Displaytext
- Intern wird für DB-Queries stets der Enum-Name verwendet
- `StringConverter<ProcessingStatus>` implementieren
**Versuche-Tabelle (Detailbereich):**
- Status-Spalte: `ProcessingStatusPresentation`-Mapping anwenden
- Kein Enum-Rohname darf für Endnutzer sichtbar sein
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
---
### #83 KI-Begründung bei SUCCESS-Versuch verwirrend leer
#### Problem
Im Detailbereich wird bei einem Versuch mit Status `SUCCESS` die
KI-Begründungs-TextArea leer angezeigt. Nutzer verstehen nicht, ob das
ein Fehler ist oder ob tatsächlich keine Begründung vorliegt.
#### Lösung
**Platzhalter über JavaFX `promptText` (kein echter Textinhalt):**
Bei leerem oder null `ai_reasoning` gilt:
```java
textArea.setText("");
textArea.setPromptText("Keine KI-Begründung für diesen Versuch gespeichert.");
```
Der `promptText` wird von JavaFX automatisch gedimmt dargestellt und ist
**nicht kopierbar, nicht speicherbar, nicht als Nutzdaten behandelbar**.
Kein Vermischen von Daten und UI-Platzhaltertext.
Die TextArea bleibt sichtbar ein leeres Feld ohne Erklärung ist schlechter
als ein erklärender Platzhalter.
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case,
keine DB-Änderung.
---
### #84 Aktionsbuttons nach Laufende nicht sofort reaktiviert
#### Problem
Nach Abschluss eines Verarbeitungslaufs bleiben die Aktionsbuttons im Verlauf-Tab
(„Status zurücksetzen", „Eintrag löschen") dauerhaft deaktiviert.
#### Lösung
**Ereignisgetriebene Button-State-Neuberechnung:**
Der Button-State wird nach jedem Lauf-Terminierungsereignis neu berechnet
unabhängig vom Grund der Terminierung:
- Erfolgreicher Laufabschluss
- Fehlerabbruch (Exception im Worker)
- Nutzerabbruch
- Leerlauf (keine Dateien zu verarbeiten)
Nach Terminierung wird, sofern eine Auswahl in der Verlauf-Tabelle besteht,
der zugehörige Aktionsbutton-State **ereignisgetrieben** aktiviert
ohne dass der Nutzer die Auswahl erneuern oder den Tab wechseln muss.
**Code-Analyse erforderlich:** Claude Code analysiert den genauen Signal-Pfad
(Laufabschluss-Event → UI-Komponente) und korrigiert die fehlende
`Platform.runLater()`-Kopplung.
**Technisch:** Vermutlich `adapter-in-gui` und ggf. `bootstrap` (Bridge-Verdrahtung).
Kein neuer Port, kein Use-Case.
---
### #88 FAILED_FINAL ohne Fehlerursache im Verlauf-Tab
#### Problem
Der Detailbereich zeigt bei `FAILED_FINAL`-, `FAILED_RETRYABLE`- und
`SKIPPED_FINAL_FAILURE`-Einträgen keine Fehlerursache an.
Der Nutzer sieht nur den Status-Icon.
#### Lösung
**Schema-/Code-Analyse als blockierender erster Schritt:**
Vor jeder weiteren Implementierung dokumentiert Claude Code verbindlich,
welcher Fall vorliegt:
**Fall A geeignetes Fehlerfeld bereits vorhanden:**
`processing_attempt` enthält bereits ein nutzbares Fehlerfeld.
→ Keine Migration. GUI und Abfrage werden um die Anzeige erweitert.
**Fall B kein geeignetes Fehlerfeld vorhanden:**
→ Flyway-Migration mit der **nächsten freien Versionsnummer** zum Zeitpunkt
der Implementierung. Fehlerdetails können nur für ab V3.1 erzeugte
Verarbeitungsversuche gespeichert werden. Bestehende Einträge bleiben
unverändert und zeigen den Platzhalter „Keine Fehlerdetails gespeichert."
**Fall C Fehlerdetails werden bisher nur im Log gespeichert:**
→ Migration zwingend erforderlich. Zusätzlich muss der Fehlerpfad der
Verarbeitungslogik um Persistierung der Fehlerdetails erweitert werden.
**Domain-Modul-Einschränkung:**
`pdf-umbenenner-domain` bleibt unverändert, sofern die benötigten
Fehlerdetails ausschließlich über bestehende oder application-nahe
History-DTOs transportiert werden können.
Falls das fachliche Attempt-Modell im Domain-Modul liegt und für die
Anzeige erweitert werden muss, ist eine **minimale Domain-Erweiterung zulässig**.
Keine Änderung an der fachlichen Kernverarbeitung.
**Datenmodell (bei Migration Fall B oder C):**
```sql
-- Versionsnummer = nächste freie Flyway-Version zum Zeitpunkt der Implementierung
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
```
`failure_details` enthält eine **nutzerverständliche, gekürzte Fehlerbeschreibung**.
Provider- oder Exception-Meldungen werden **nicht roh persistiert**
gespeichert wird eine kontrolliert erzeugte Kurzmeldung aus bekannten
Fehlerkategorien oder eine bereinigte/gekürzte Message ohne Stacktrace,
API-Keys oder vollständige Provider-Rohantworten.
Die Begrenzung auf **1000 Zeichen wird spätestens vor Persistierung im
DB-Adapter erzwungen**: Längere Texte werden gekürzt und mit „…" markiert.
Falls bereits vorher ein zentrales Fehler-Mapping existiert, darf dort
gekürzt werden. Entscheidend: in die DB gelangen nur gekürzte, bereinigte
Fehlerdetails. Kein SQL-`CHECK`-Constraint (um Alt-/Importdaten nicht
zu blockieren).
**„Letzter Versuch" Definition:**
Die angezeigte Fehlerursache stammt aus dem Versuch mit dem höchsten
`attempt_number`. Bei Gleichstand wird der mit dem jüngsten `ended_at` verwendet.
Die Sortierung wird im Rahmen der Code-Analyse gegen das vorhandene Schema
verifiziert. Falls `attempt_number` oder `ended_at` nicht existieren, wird
die technisch eindeutige Sortierung des Attempt-Verlaufs verwendet und
in der Implementierungsnotiz dokumentiert.
**Anzuzeigende Status:**
Fehlerursache wird angezeigt bei:
- `FAILED_FINAL`
- `FAILED_RETRYABLE`
- `SKIPPED_FINAL_FAILURE` (zeigt die letzte bekannte Fehlerursache des
zugrundeliegenden fehlgeschlagenen Attempts fachlich konsistent,
da `SKIPPED_FINAL_FAILURE` direkte Folge eines endgültigen Fehlschlags ist)
Bei fehlendem `failure_details` (NULL oder leer): Platzhaltertext via `promptText`
analog zu #83.
**Technisch:** `adapter-in-gui` (Anzeige), ggf. `adapter-out-db`
(Abfrage-Erweiterung), ggf. Flyway-Migration, ggf. minimale Domain-Erweiterung.
---
### #91 Lock-File relativer Pfad
#### Problem
Der Lock-Mechanismus nutzt einen konfigurierten oder Standard-Pfad für die
Lock-Datei. Bei relativem Pfad ist das Verzeichnis abhängig vom aktuellen
Arbeitsverzeichnis. Liegt die JAR unter `C:\Program Files`, ist das Verzeichnis
zudem nicht beschreibbar.
#### Lösung
**Verhalten abhängig vom Pfadtyp:**
**Absolut konfigurierter Pfad:**
Wird unverändert verwendet. Schlägt das Anlegen fehl, erfolgt **kein Fallback**
der Nutzer hat den Speicherort explizit vorgegeben. Start bricht mit klarer
Fehlermeldung ab.
**Relativer oder nicht konfigurierter (Default-)Pfad zweistufige Fallback-Strategie:**
1. **Primär:** Auflösung relativ zum Verzeichnis der JAR-Datei
(`CodeSource.getLocation()`)
2. **Fallback:** Auflösung relativ zu `user.home`
3. **Abbruch:** Erst wenn auch `user.home` fehlschlägt
**Parent-Verzeichnisse** werden bei Bedarf automatisch angelegt
(`Files.createDirectories()`).
Der final verwendete **absolute Pfad wird beim Start geloggt** (INFO-Level):
```
Lock-Datei: C:\Users\Funny\Documents\pdf-umbenenner.lock
```
**Gilt für GUI- und Headless-Start.**
**Code-Analyse erforderlich:** Claude Code ermittelt die aktuelle
Lock-Implementierungslokation (`bootstrap` oder `adapter-out-db`).
---
## GUI-Features
### #82 Verlauf-Tab: Live-Filter bei Suche
#### Problem
Die Suche im Verlauf-Tab wird nur durch expliziten Klick auf den Such-Button
ausgelöst. Das erfordert unnötige Interaktion bei jeder Suchanpassung.
#### Lösung
**Live-Filter mit Debounce und Generation-Counter:**
- Das Suchfeld erhält einen `ChangeListener` auf die `textProperty()`
- Bei jeder Texteingabe startet ein JavaFX-`Timeline`-Debounce-Timer (300 ms)
- Nach 300 ms ohne weitere Eingabe wird die DB-Abfrage auf einem Worker-Thread gestartet
**Race-Condition-Schutz via Generation-Counter:**
Jede gestartete Suchanfrage erhält eine aufsteigende Generations-ID (atomarer
`long`-Counter). Der Worker-Thread trägt seine Generations-ID ins Ergebnis.
Beim `Platform.runLater()`-Callback wird das Ergebnis nur in die UI übernommen,
wenn die Generations-ID noch aktuell ist veraltete Worker-Ergebnisse
werden verworfen.
**Such-Button und Enter-Taste:**
- Klick auf Such-Button oder Enter im Suchfeld: Debounce-Timer sofort abgebrochen,
Suche unverzüglich gestartet
- Barrierefreiheit: Such-Button bleibt erhalten
**Auswahlverhalten nach neuen Suchergebnissen:**
Nach jeder Übernahme neuer Suchergebnisse wird die Tabellenauswahl
**vollständig geleert**. Detailbereich und Aktionsbuttons werden entsprechend
zurückgesetzt. Das ist robuster als ein Abgleich der alten Auswahl gegen
die neue Ergebnisliste und vermeidet Wechselwirkungen mit #86.
**Leeres Suchfeld:** Zeigt alle Einträge (bis LIMIT 501).
**Technisch:** Ausschließlich `adapter-in-gui`. Die bestehende Suchabfrage via
`GuiHistoryOverviewPort` wird unverändert wiederverwendet.
---
### #86 Mehrfachauswahl im Verlauf-Tab
#### Problem
Der Verlauf-Tab erlaubt nur Einzelauswahl. Bulk-Operationen sind nicht möglich.
#### Lösung
**Multi-Select-Modus:**
```java
tableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
```
JavaFX stellt damit Strg+Klick und Shift+Klick automatisch bereit.
**Strg+A Fokusabhängig:**
Strg+A selektiert alle sichtbaren Tabelleneinträge **nur, wenn die Verlauf-Tabelle
den Fokus besitzt**. Liegt der Fokus im Suchfeld, bleibt Strg+A die normale
Textauswahl im Suchfeld.
**Detailbereich bei Mehrfachauswahl:**
- Genau 1 Eintrag: Detailbereich wie bisher
- Mehrere Einträge: Platzhaltertext „X Einträge ausgewählt."
**Snapshot der fachlichen Schlüssel vor Worker-Thread-Start:**
Vor dem Start einer Bulk-Operation wird ein **unveränderlicher Snapshot der
fachlichen Schlüssel** erstellt, die die bestehenden Reset-/Delete-Use-Cases
erwarten (typischerweise Fingerprints, sofern das die vorhandene Use-Case-Signatur
erwartet). Der Worker-Thread arbeitet ausschließlich auf diesem Snapshot
nie auf einer Live-`ObservableList`, die sich während der Operation ändern könnte.
**Aktionsbuttons bei Mehrfachauswahl:**
| Aktion | Verhalten |
|---|---|
| „Status zurücksetzen" | Aktiv bei ≥ 1 Auswahl; Bestätigungsdialog: „X Einträge zurücksetzen?" |
| „Eintrag löschen" | Aktiv bei ≥ 1 Auswahl; Bestätigungsdialog: „X Einträge unwiderruflich löschen?" |
**Bulk-Fehlerstrategie (Partial Success):**
Schlägt eine Operation bei einzelnen Einträgen fehl, werden die restlichen
trotzdem abgearbeitet. Nach Abschluss erscheint ein **kompakter**
Zusammenfassungsdialog:
> „X von Y Einträgen erfolgreich verarbeitet. Z Einträge konnten nicht
> verarbeitet werden."
Detaillierte Einzelfehler werden geloggt, nicht in den Dialog gestopft.
**Ausführung:** Bulk-Operationen rufen die bestehenden Use-Cases
(`DefaultResetDocumentStatusUseCase`, `DefaultDeleteDocumentHistoryUseCase`)
sequenziell auf dem Worker-Thread auf. Keine neuen Use-Cases erforderlich.
**Sperren während Lauf:** Alle Aktions-Buttons deaktiviert während eines
aktiven Verarbeitungslaufs.
**Technisch:** Ausschließlich `adapter-in-gui`. Keine neuen Ports oder Use-Cases.
---
### #87 Neue leere SQLite-Datenbank anlegen
#### Problem
Will der Nutzer mit einer frischen Datenbank starten, muss er die Datei
manuell löschen. Das ist umständlich und fehleranfällig.
#### Lösung
**Neuer Menüpunkt:**
`Datenbank → Neue Datenbank anlegen...`
(Nur aktiv wenn kein Verarbeitungslauf läuft.)
**Eigentümer des aktiven Datenbankkontexts:**
Der Runtime-Wechsel der aktiven Datenbank erfordert eine zentrale Komponente,
die den aktiven Datenbankkontext besitzt. Vor der Implementierung analysiert
Claude Code, ob eine solche Komponente bereits existiert.
- **Fall A wechselbarer DB-Kontext vorhanden:** Vorhandene Komponente
wird genutzt/erweitert.
- **Fall B kein wechselbarer DB-Kontext vorhanden:** Es wird ein minimaler
`ActiveDatabaseContextPort` eingeführt (Outbound-Port in `application`,
Adapter in `bootstrap` oder `adapter-out-db`). Dieser Port ist die einzige
Stelle, an der die aktive DB-Referenz umgestellt wird.
**Der DB-Wechsel darf nicht im JavaFX-Code versteckt werden.**
Der Use-Case `DefaultCreateNewDatabaseUseCase` orchestriert den Wechsel;
die physische Umstellung der Verbindung delegiert er über den Port.
**Ablauf (atomar aus Anwendungssicht):**
1. `FileChooser` öffnet (Filter: `*.sqlite`); Nutzer wählt Zieldatei
2. **Pfad-Sicherheitsprüfung:**
Die aktive DB und die gewählte Zieldatei werden über **normalisierte,
absolut aufgelöste Pfade** verglichen kein Rohstring-Vergleich.
Für existierende Dateien wird `toRealPath()` verwendet; für noch nicht
existierende Dateien wird der Parent-Pfad real aufgelöst und der Dateiname
normalisiert verglichen. Unter Windows erfolgt der Vergleich case-insensitive.
Bei Übereinstimmung: klare Fehlermeldung, kein Überschreiben.
3. Existiert die Zieldatei (andere als aktive DB): Bestätigungsdialog
„Die Datei existiert bereits. Überschreiben?"
4. **GUI-Sperre:** Während Anlage und Wechsel befindet sich die GUI in einem
`DB-Busy`-Zustand. Alle DB-lesenden und DB-schreibenden Aktionen
(Live-Suche, Bulk-Reset, Bulk-Delete, Verlauf-Refresh, erneuter
Klick auf „Neue Datenbank anlegen") sind deaktiviert. Der Zustand
wird nach Erfolg oder Fehler zuverlässig zurückgesetzt.
5. Neue SQLite-Datei wird als **temporäre Datei im Zielverzeichnis** erzeugt
6. Flyway führt alle verfügbaren Migrationsskripte gegen die temporäre Datei aus
(`migrate()` auf neuesten Schema-Stand)
7. Neue DB-Verbindung wird **testweise geöffnet und geprüft** (gegen Temp-Datei).
Der Verbindungstest prüft mindestens:
- SQLite-Verbindung kann geöffnet werden
- Flyway-Schema-History ist vorhanden
- Eine einfache Leseabfrage gegen Schema-Metadaten ist erfolgreich
8. Erst nach erfolgreichem Test: temporäre Datei zur Zieldatei verschoben.
Bei bereits existierender, bestätigter Zieldatei wird
`Files.move(tempFile, targetFile, ATOMIC_MOVE, REPLACE_EXISTING)` verwendet,
sofern vom Dateisystem unterstützt. Die vorhandene Zieldatei wird vorher
**nicht separat gelöscht**. Wird die Kombination `ATOMIC_MOVE + REPLACE_EXISTING`
nicht unterstützt, bricht der Vorgang mit klarer Fehlermeldung ab
kein unsicherer halb-atomarer Fallback.
9. Aktive DB-Referenz der Anwendung umgestellt (via `ActiveDatabaseContextPort`)
10. Verlauf-Tab neu geladen → zeigt „Noch keine Verarbeitungen vorhanden."
11. Statuszeile aktualisiert DB-Pfad
12. DB-Pfad im Konfigurationsmodell geändert → Konfig-Tab wechselt in Dirty-State
13. Statuszeile oder Meldungsbereich zeigt:
„Neue Datenbank ist aktiv. Konfiguration speichern, damit diese DB
beim nächsten Start verwendet wird."
**Fehlerfall ohne partielle Änderung:**
Schlägt ein Schritt (Anlegen, Flyway, Verbindungstest, Move) fehl, bleibt die
bisher aktive DB **vollständig unverändert in Betrieb**. Die temporäre Datei
wird gelöscht. Fehlerdialog mit konkreter Meldung.
**Headless:** Die Funktion ist ausschließlich GUI-seitig aufrufbar.
`adapter-in-cli` ist nicht betroffen.
**Architektur:**
| Komponente | Typ | Modul | Zweck |
|---|---|---|---|
| `CreateNewDatabaseUseCase` | Inbound-Port-Interface | `application` | Vertrag: `createNewDatabase(Path)` |
| `DefaultCreateNewDatabaseUseCase` | Use-Case-Impl. | `application` | Atomarer DB-Wechsel: Temp-Datei, Flyway, Test, Move, Kontext-Umstellung |
| `DatabaseCreationPort` | Outbound-Port | `application` | `createAndInitialize(Path tempFile)` |
| `ActiveDatabaseContextPort` | Outbound-Port | `application` | `switchActiveDatabase(Path newDbFile)` Eigentümer des Laufzeitkontexts |
| `GuiCreateNewDatabasePort` | Bridge-Interface | `adapter-in-gui` | Brücke zum Use-Case |
| `SqliteDatabaseCreationAdapter` | Outbound-Adapter | `adapter-out-db` | SQLite-Temp-Datei erzeugen, Flyway migrate auf latest, Verbindung testen |
| `SqliteActiveDatabaseContextAdapter` | Outbound-Adapter | `bootstrap` oder `adapter-out-db` | Umschalten der aktiven DB-Referenz (Analyse erforderlich) |
---
### #32 Mausrad-Zoom in PDF-Vorschau
#### Problem
Die PDF-Vorschau lässt sich nur über die Zoom-Buttons skalieren.
Ein Mausrad-Zoom fehlt.
#### Lösung
**Scroll-Event auf der PDF-Vorschau-Komponente:**
```java
scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
if (event.isControlDown()) {
accumulateAndApplyZoomDelta(event.getDeltaY());
event.consume(); // immer konsumieren bei Strg, kein paralleles Scrollen
}
// ohne Strg: normales Scrollen bleibt
});
```
**Bei gedrückter Strg-Taste werden ScrollEvents grundsätzlich konsumiert**,
damit kein paralleles Scrollen im ScrollPane erfolgt auch wenn der Delta
zu klein für einen Zoomschritt ist.
**Delta-Akkumulation für Trackpad-Kompatibilität:**
Sehr kleine Trackpad-Deltas werden **intern akkumuliert**, bis die Mindestschwelle
für einen Zoomschritt erreicht ist. Kein Verwerfen: akkumulierte Deltas
ergeben bei genug Trackpad-Wischbewegung sauber einen Zoomschritt.
Als Orientierungswert gilt ±10 % je „Notch" eines Standard-Mausrads.
**Zoom-Verhalten:**
| Parameter | Wert |
|---|---|
| Auslöser | Strg + Mausrad |
| Schrittweite | Vorzeichenbasiert auf akkumuliertem `deltaY`, ca. 10 % je Notch |
| Minimum | 10 % |
| Maximum | 500 % |
| Zurücksetzen bei neuem PDF | Ja (Zoom auf Fit-to-Width) |
**Fit-to-Width-Modus:**
Nach manuellem Strg+Mausrad-Zoom verlässt die Vorschau den Fit-to-Width-Modus.
Fit-to-Width wird erst wieder aktiv, wenn ein neues PDF geladen oder der
Fit-to-Width-Button explizit erneut betätigt wird.
**Viewport-Stabilität:**
Beim Zoom bleibt die sichtbare Viewport-Mitte möglichst erhalten.
**Zoom-State-Konsistenz:**
Der Zoom-State wird über dieselbe Variable geführt, die auch die
Toolbar-Zoom-Buttons bedienen.
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
---
## Architektur-Zusammenfassung
### Neue Inbound-Port-Interfaces und Use-Cases
| Komponente | Typ | Modul | Zweck | Issue |
|---|---|---|---|---|
| `CreateNewDatabaseUseCase` | Inbound-Port-Interface | `application` | Vertrag für DB-Anlage | #87 |
| `DefaultCreateNewDatabaseUseCase` | Use-Case-Impl. | `application` | Atomarer DB-Wechsel via Temp-Datei + Port-Delegation | #87 |
### Neue Outbound-Ports
| Komponente | Modul | Zweck | Issue |
|---|---|---|---|
| `DatabaseCreationPort` | `application` | Temp-Datei erzeugen, Flyway, Verbindungstest | #87 |
| `ActiveDatabaseContextPort` | `application` | `switchActiveDatabase(Path)` Laufzeit-DB-Kontext | #87 |
### Neue Bridge-Interfaces (adapter-in-gui)
| Interface | Zweck | Issue |
|---|---|---|
| `GuiCreateNewDatabasePort` | Brücke zur DB-Anlage | #87 |
### Neue Adapter
| Adapter | Modul | Zweck | Issue |
|---|---|---|---|
| `SqliteDatabaseCreationAdapter` | `adapter-out-db` | SQLite-Temp-Datei, Flyway migrate auf latest, Test | #87 |
| `SqliteActiveDatabaseContextAdapter` | `bootstrap` oder `adapter-out-db` | Umschalten der aktiven DB-Referenz (Lokation via Code-Analyse) | #87 |
### Geänderte Komponenten (adapter-in-gui)
| Komponente | Änderung | Issues |
|---|---|---|
| `GuiHistoryTab` | Multi-Select + Schlüssel-Snapshot, Live-Filter + Generation-Counter + Auswahl leeren, Fehlerursache, Platzhalter via promptText, Tooltips, DB-Busy-Sperre | #82, #83, #86, #88, #77, #87 |
| `GuiConfigTab` | Dirty-State mit Baseline-Snapshot + loadingInProgress, Tab-Titel, Dialog, Kopplung mit #87 | #80 |
| `GuiTooltipTexts` | Neue Tooltip-Konstanten; TableColumn-Header via Column-Graphic-Pattern | #77 |
| Verlauf-Detailbereich | Enum-Displaytext (alle 8 Werte), Fehlerursache für FAILED/SKIPPED_FINAL | #81, #88 |
| Status-ComboBox | `StringConverter<ProcessingStatus>`, „Alle Status" als GUI-interner Null-Filter | #81 |
| PDF-Vorschau-Komponente | Delta-Akkumulation, Strg+Scroll konsumiert, Viewport-Stabilität, Fit-to-Width-Modus | #32 |
| Lauf-Abschluss-Signalkette | Ereignisgetriebene Button-State-Neuberechnung für alle Terminierungsgründe | #84 |
### Geänderte Komponenten (sonstige)
| Komponente | Modul | Änderung | Issue |
|---|---|---|---|
| Lock-File-Auflösung | `bootstrap` oder `adapter-out-db` | Absolut: direkt + Abbruch; Relativ: JAR-Dir → user.home → Abbruch; Parent-Dirs; Logging | #91 |
### Nicht geändert
- `pdf-umbenenner-domain` keine Änderungen, außer ggf. minimale Erweiterung
für #88 falls Attempt-Modell dort liegt (zulässig, keine Kernverarbeitungslogik)
- `pdf-umbenenner-adapter-in-cli` keine neuen Funktionen
- Headless-Verarbeitungslogik vollständig unberührt
- Kernverarbeitungslogik (PDF lesen → KI → umbenennen)
---
## Datenbankmigrationen
Flyway ist die einzige Schema-Evolutionsquelle.
### Potenzielles Migrationsskript (abhängig von Code-Analyse #88)
Vor der Implementierung von #88 dokumentiert Claude Code verbindlich,
ob ein Fehlerfeld bereits im Schema existiert (Fall A / B / C siehe #88).
**Nur bei Fall B oder C:**
```sql
-- Fehlerdetails in processing_attempt ergänzen
-- Versionsnummer = nächste freie Flyway-Version zum Zeitpunkt der Implementierung
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
```
- `failure_details`: nutzerverständliche, gekürzte Fehlerbeschreibung;
Begrenzung auf 1000 Zeichen **vor Persistierung im Adapter** erzwungen,
Kürzung mit „…"; kein SQL-`CHECK`-Constraint
- Bestehende Zeilen erhalten automatisch `NULL` kein Datenverlust
- Alte Einträge ohne Fehlerdetails zeigen `promptText`-Platzhalter in der GUI
---
## Definition of Done (V3.1 gesamt)
- [ ] Alle 11 Issues implementiert und einzeln getestet
- [ ] `mvn clean verify` grün (alle Module, kein `-DskipTests`)
- [ ] `mvn clean install -Drevision=3.1.0` Build ohne Fehler
- [ ] Manueller GUI-Produkttest durchgeführt (Green build ≠ fertige Software)
- [ ] Keine Enum-Rohnamen in der GUI sichtbar (alle 8 Statuswerte mit Displaytext)
- [ ] Alle fehlenden Tooltips vorhanden; TableColumn-Header via Column-Graphic-Pattern
- [ ] Dirty-Indikator Konfig-Tab: kein programmgesteuertes Feuern, Baseline-Snapshot korrekt
- [ ] Live-Filter: 300 ms Debounce, Generation-Counter, Auswahl nach Suche geleert
- [ ] Mehrfachauswahl: Strg+A nur bei Tabellenfokus; Schlüssel-Snapshot; Partial-Success-Dialog
- [ ] `FAILED_FINAL`/`FAILED_RETRYABLE`/`SKIPPED_FINAL_FAILURE`: Fehlerursache sichtbar (oder Platzhalter)
- [ ] Leere `ai_reasoning`: `promptText`-Platzhalter (kein echter Text)
- [ ] Aktionsbuttons ereignisgetrieben reaktiviert nach allen Terminierungsgründen
- [ ] #87 Code-Analyse: DB-Kontext-Eigentümer dokumentiert (Fall A oder B)
- [ ] #87: Atomarer Ablauf via Temp-Datei; Pfadvergleich normalisiert + case-insensitive
- [ ] #87: Aktive DB bleibt bei Fehler unverändert; DB-Busy-Sperre korrekt zurückgesetzt
- [ ] #87: Flyway auf neuesten Stand; Hinweismeldung nach Wechsel
- [ ] Strg+Mausrad-Zoom: Delta-Akkumulation, immer konsumiert bei Strg, 10%500%
- [ ] Lock-File: Absolut direkt; Relativ zweistufig; Parent-Dirs; Pfad geloggt
- [ ] Code-Kommentare auf Deutsch; Logging auf Deutsch
- [ ] JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
- [ ] `betrieb.md` und `gui-bedienanleitung.md` auf V3.1-Stand gebracht
- [ ] Freigabedokument `freigabe-v3_1.md` erstellt
---
## Abnahmekriterien je Feature
### #77 Fehlende Tooltips
- [ ] Vollständige Bestandsaufnahme: Liste aller Elemente ohne Tooltip erstellt
- [ ] Alle identifizierten Elemente haben Tooltips (Anzahl aus Bestandsaufnahme)
- [ ] TableColumn-Header: Column-Graphic mit Label+Tooltip, kein Skin-/Lookup-Hack
- [ ] Column-Graphic: Sortierung, Header-Breite und CSS nicht sichtbar verschlechtert
- [ ] Neue Konstanten ausschließlich in `GuiTooltipTexts`, keine hartcodierten Strings
### #80 Dirty-Indikator Konfig-Tab
- [ ] Tab-Titel `* Konfiguration` nur nach echter Nutzeränderung gegenüber Baseline-Snapshot
- [ ] Programmgesteuertes Laden setzt kein Dirty-Flag (`loadingInProgress`-Schutz)
- [ ] Tab-Titel `Konfiguration` nach Speichern
- [ ] Bestätigungsdialog bei Laden neuer Konfig mit Dirty State
- [ ] DB-Pfad-Wechsel via #87 setzt Konfig-Tab dirty
- [ ] UX identisch zum Prompt-Tab (Sternchen, Dialog, Reset)
### #81 Enum-Bezeichnungen
- [ ] Status-ComboBox: „Alle Status" als erster Eintrag (GUI-interner Null-Filter)
- [ ] Status-ComboBox: alle 8 Statuswerte als Displaytext
- [ ] Versuche-Tabelle: alle 8 Statuswerte als Displaytext
- [ ] DB-Queries intern weiterhin mit Enum-Namen
- [ ] Kein Enum-Rohname für Endnutzer sichtbar
### #82 Live-Filter
- [ ] Suche startet nach 300 ms Tipp-Pause automatisch
- [ ] Generation-Counter: veraltete Worker-Ergebnisse werden verworfen
- [ ] Such-Button / Enter: sofortige Suche, Debounce abgebrochen
- [ ] Auswahl nach neuen Suchergebnissen vollständig geleert
- [ ] Leeres Suchfeld zeigt alle Einträge
- [ ] Worker-Thread, UI via `Platform.runLater()`
### #83 KI-Begründung leer
- [ ] `textArea.setPromptText(...)` bei leerem/null `ai_reasoning`
- [ ] `textArea.setText("")` kein Platzhaltertext als echter Inhalt
- [ ] TextArea bleibt sichtbar
### #84 Buttons reaktivieren
- [ ] Aktionsbuttons während Lauf deaktiviert
- [ ] Reaktivierung ereignisgetrieben nach: Erfolg, Fehlerabbruch, Nutzerabbruch, Exception
- [ ] Keine manuellen Workarounds notwendig
### #86 Mehrfachauswahl
- [ ] `SelectionMode.MULTIPLE` aktiv
- [ ] Strg+A nur bei Tabellenfokus (kein Konflikt mit Suchfeld)
- [ ] Strg+Klick, Shift+Klick korrekt
- [ ] Detailbereich: „X Einträge ausgewählt." bei Mehrfachauswahl
- [ ] Schlüssel-Snapshot vor Worker-Thread-Start
- [ ] Bulk-Reset: Bestätigungsdialog + Partial-Success-Dialog
- [ ] Bulk-Delete: Bestätigungsdialog + Partial-Success-Dialog
- [ ] Aktionen während Lauf gesperrt
### #87 Neue Datenbank anlegen
- [ ] Code-Analyse: DB-Kontext-Eigentümer dokumentiert, Fall A oder B entschieden
- [ ] Menüpunkt vorhanden, nur außerhalb von Läufen aktiv
- [ ] Aktive DB über normalisierten Pfadvergleich (case-insensitive, toRealPath) erkannt
- [ ] Bestehende Fremddatei: Überschreiben-Bestätigung
- [ ] DB-Busy-Sperre während Anlage aktiv; nach Erfolg/Fehler zuverlässig zurückgesetzt
- [ ] Neue DB als Temp-Datei; Flyway auf neuesten Stand
- [ ] Verbindungstest: Verbindung öffnen, Flyway-History prüfen, Leseabfrage erfolgreich
- [ ] Move mit `ATOMIC_MOVE + REPLACE_EXISTING`; vorhandene Datei nicht vorher separat löschen
- [ ] Kein halb-atomarer Fallback bei nicht unterstützter Kombination
- [ ] Fehlerfall: Temp-Datei gelöscht, aktive DB unverändert, Fehlerdialog
- [ ] `ActiveDatabaseContextPort.switchActiveDatabase()` schaltet Referenz um
- [ ] Verlauf-Tab: „Noch keine Verarbeitungen vorhanden."
- [ ] Statuszeile aktualisiert DB-Pfad
- [ ] Konfig-Tab wechselt in Dirty-State
- [ ] Hinweismeldung: Konfiguration speichern nicht vergessen
### #88 Fehlerursache FAILED_FINAL
- [ ] Schema-/Code-Analyse: Fall A/B/C dokumentiert vor Implementierung
- [ ] Ggf. Flyway-Migration mit nächster freier Versionsnummer
- [ ] Sortierung für „letzter Versuch" gegen Schema verifiziert
- [ ] Detailbereich: `failure_details` bei `FAILED_FINAL`, `FAILED_RETRYABLE`, `SKIPPED_FINAL_FAILURE`
- [ ] NULL/leer: `promptText`-Platzhalter
- [ ] 1000-Zeichen-Grenze spätestens vor DB-Persistierung erzwungen, Kürzung mit „…"
- [ ] Keine rohen Provider-/Exception-Meldungen persistiert
### #91 Lock-File Pfad
- [ ] Absoluter Pfad: direkt verwendet, kein Fallback, Abbruch bei Fehler
- [ ] Relativer Pfad: erst JAR-Verzeichnis, dann `user.home`, dann Abbruch
- [ ] Parent-Verzeichnisse automatisch angelegt
- [ ] Absoluter Pfad beim Start geloggt (INFO)
- [ ] Gilt für GUI- und Headless-Start
### #32 Mausrad-Zoom
- [ ] Strg+Scroll: Event grundsätzlich konsumiert (kein paralleles Scrollen)
- [ ] Delta-Akkumulation für kleine Trackpad-Deltas
- [ ] Zoom 10%500%, ca. 10 % je Notch
- [ ] Ohne Strg: normales Scrollen
- [ ] Viewport-Mitte beim Zoom möglichst stabil
- [ ] Fit-to-Width-Modus verlassen nach manuellem Zoom
- [ ] Zoom-Reset bei neuem PDF (Fit-to-Width)
- [ ] Zoom-State konsistent mit Toolbar-Zoom-Buttons
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.nio.file.Path;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
/**
* Callback invoked by the workspace on a background thread after a configuration file
* has been successfully loaded from disk.
* <p>
* Bootstrap supplies an implementation that builds the application run context
* (migrate load validate schema-init sequence) and, on success, also initialises
* the automatic scheduler. The workspace calls this initializer inside the same
* background submit that loads the editor state, so the JavaFX Application Thread is
* never blocked.
* <p>
* In isolated GUI tests a {@link #noOp() no-op} implementation can be used so that no
* Bootstrap wiring is required.
*/
@FunctionalInterface
public interface GuiApplicationContextInitializer {
/**
* Attempts to initialise the application run context for the supplied configuration file.
* <p>
* If context initialisation succeeds and the configuration enables the scheduler, the
* scheduler is also wired and its use case is returned in the result. The caller is
* responsible for handing the scheduler use case to the scheduler tab on the JavaFX
* Application Thread via {@code Platform.runLater}.
* <p>
* This method must be called on a background worker thread, not on the JavaFX Application
* Thread.
*
* @param configFilePath path to the {@code .properties} configuration file; must exist on disk
* @return the result of the initialisation attempt; never {@code null}
*/
InitResult initialize(Path configFilePath);
/**
* Returns a no-op initializer that always reports success and no scheduler.
* <p>
* Suitable for GUI tests and startup paths where no Bootstrap wiring is available.
*
* @return no-op initializer; never {@code null}
*/
static GuiApplicationContextInitializer noOp() {
return configFilePath -> new InitResult(Optional.empty(), Optional.empty());
}
/**
* Result of a context initialisation attempt.
*
* @param contextError empty on success; a human-readable German error message
* when initialisation failed the GUI remains functional
* but falls back to per-run initialisation for batch runs;
* must not be {@code null}
* @param schedulerControlUseCase the scheduler use case when the configuration enables the
* scheduler and initialisation succeeded; empty otherwise;
* must not be {@code null}
*/
record InitResult(
Optional<String> contextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase) {
}
}
@@ -0,0 +1,41 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase.CreateNewDatabaseResult;
/**
* GUI-internes Bridge-Interface zwischen dem Workspace und dem
* {@link de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase}.
* <p>
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Es ist eine modul-interne Brücke, über die Bootstrap die DB-Anlage- und Wechsellogik
* für die GUI bereitstellt, ohne dass der GUI-Adapter direkt auf den Use-Case oder die
* darunterliegenden Outbound-Ports zugreift.
* <p>
* <strong>Threading:</strong> Implementierungen dürfen blockierende Operationen
* ausführen (Flyway-Migration, Verbindungstest, atomares Verschieben einer Datei).
* Sie müssen daher von einem Hintergrund-Worker-Thread aufgerufen werden. Der Aufruf
* blockiert, bis das Ergebnis vollständig vorliegt.
*/
@FunctionalInterface
public interface GuiCreateNewDatabasePort {
/**
* Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und
* stellt die aktive Datenbankreferenz auf diese Datei um.
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei,
* oder {@code null}, wenn (noch) keine Konfiguration
* geladen ist. Die Bootstrap-Implementierung leitet
* daraus den Pfad der aktuell aktiven SQLite-Datei ab,
* sofern noch kein Override vom
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}
* gesetzt ist.
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht
* {@code null} sein
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler;
* nie {@code null}
*/
CreateNewDatabaseResult createNewDatabase(Path configFilePath, Path targetFile);
}
@@ -51,6 +51,10 @@ import javafx.application.Platform;
* {@code Platform.runLater}.
*/
public final class GuiModelCatalogCoordinator {
private static final String LOG_MODEL_FETCH_FMT = "GUI-Modellabruf: {} (Provider: {})";
private static final String OPERATION_MODELLABRUF = "Modellabruf";
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
@@ -203,7 +207,7 @@ public final class GuiModelCatalogCoordinator {
String previousManualValue) {
// Remove any previous message entries from an earlier retrieval so messages do not
// accumulate across repeated triggers of the same retrieval action.
pendingMessages.removeIf(msg -> "Modellabruf".equals(msg.source().orElse("")));
pendingMessages.removeIf(msg -> OPERATION_MODELLABRUF.equals(msg.source().orElse("")));
String displayName = displayNameFor(family);
@@ -213,28 +217,28 @@ public final class GuiModelCatalogCoordinator {
container.applyModelList(models, previousManualValue);
String message = "Modellliste für " + displayName + " geladen ("
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, "Modellabruf"));
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, OPERATION_MODELLABRUF));
LOG.info(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
}
case ModelCatalogResult.EmptyList emptyList -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Provider " + displayName
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf"));
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, OPERATION_MODELLABRUF));
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
}
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
+ ". Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, "Modellabruf"));
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, OPERATION_MODELLABRUF));
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
}
case ModelCatalogResult.TechnicalFailure failure -> {
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
+ "). Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, "Modellabruf"));
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, OPERATION_MODELLABRUF));
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
message, failure.errorDetail(), family.getIdentifier());
}
@@ -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);
textArea.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_TEXTAREA));
VBox.setVgrow(textArea, Priority.ALWAYS);
// Dirty-State-Tracking
textArea.textProperty().addListener((obs, oldVal, newVal) -> {
if (loadedContent != null) {
boolean nowDirty = !newVal.equals(loadedContent);
if (nowDirty != dirty) {
dirty = nowDirty;
tab.setText(dirty ? TAB_TITLE_DIRTY : TAB_TITLE);
}
}
});
// Status-Label
statusLabel.setWrapText(true);
statusLabel.setStyle("-fx-text-fill: #555555;");
// Buttons verdrahten
saveButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_SPEICHERN));
saveButton.setOnAction(e -> requestSave());
resetButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_ZURUECKSETZEN));
resetButton.setOnAction(e -> resetToDefault());
createDefaultButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_STANDARD_ANLEGEN));
createDefaultButton.setOnAction(e -> requestCreateDefault());
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
HBox buttonBar = new HBox(8, saveButton, resetButton, createDefaultButton);
buttonBar.setAlignment(Pos.CENTER_LEFT);
buttonBar.setPadding(new Insets(6, 0, 0, 0));
VBox content = new VBox(6, textArea, statusLabel, buttonBar);
content.setPadding(new Insets(12));
VBox.setVgrow(textArea, Priority.ALWAYS);
BorderPane root = new BorderPane(content);
tab.setContent(root);
// Beim Öffnen des Tabs laden (falls Konfiguration bereits vorhanden)
tab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
if (Boolean.TRUE.equals(isSelected) && loadedContent == null) {
loadPromptAsync();
}
});
}
private void applyLoadResult(de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult result) {
if (result instanceof PromptLoadingSuccess success) {
loadedContent = success.promptContent();
textArea.setText(loadedContent);
textArea.setEditable(true);
saveButton.setDisable(false);
resetButton.setDisable(false);
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
setStatus("Prompt-Datei geladen. Identifikator: " + success.promptIdentifier().identifier());
dirty = false;
tab.setText(TAB_TITLE);
LOG.info("Prompt-Editor: Prompt-Datei erfolgreich geladen (Identifikator: {}).",
success.promptIdentifier().identifier());
} else if (result instanceof PromptLoadingFailure failure) {
boolean fileNotFound = "FILE_NOT_FOUND".equals(failure.failureReason());
if (fileNotFound) {
// Datei fehlt Hinweis und Anlegen-Button anzeigen
loadedContent = null;
textArea.setEditable(false);
textArea.clear();
saveButton.setDisable(true);
resetButton.setDisable(false);
createDefaultButton.setVisible(true);
createDefaultButton.setManaged(true);
setStatus("Keine Prompt-Datei vorhanden. Legen Sie eine Standard-Datei an oder "
+ "konfigurieren Sie den Pfad im Konfigurationstab.");
LOG.info("Prompt-Editor: Keine Prompt-Datei am konfigurierten Pfad vorhanden.");
} else {
// Anderer Fehler (I/O, leer usw.)
loadedContent = null;
textArea.setEditable(false);
textArea.clear();
saveButton.setDisable(true);
resetButton.setDisable(false);
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
setStatus("Fehler beim Laden der Prompt-Datei: " + failure.failureMessage());
LOG.warn("Prompt-Editor: Laden fehlgeschlagen ({}): {}",
failure.failureReason(), failure.failureMessage());
}
}
}
private void requestSave() {
String currentText = textArea.getText();
// Leerer Prompt: Bestätigungsdialog
if (currentText.trim().isEmpty()) {
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
confirm.setTitle("Leerer Prompt");
confirm.setHeaderText("Der Prompt ist leer.");
confirm.setContentText("Wirklich eine leere Prompt-Datei speichern?");
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
Optional<ButtonType> choice = confirm.showAndWait();
if (choice.isEmpty() || choice.get() != ButtonType.OK) {
return;
}
}
setStatus("Speichere ...");
saveButton.setDisable(true);
Thread worker = threadFactory.apply(() -> {
PromptSaveResult result = promptEditorPort.save(currentText);
fxDispatcher.accept(() -> applySaveResult(result, currentText));
});
worker.start();
}
private void applySaveResult(PromptSaveResult result, String savedContent) {
saveButton.setDisable(false);
if (result instanceof PromptSaveResult.Saved saved) {
loadedContent = savedContent;
dirty = false;
tab.setText(TAB_TITLE);
setStatus("Prompt-Datei gespeichert: " + saved.absolutePath());
textArea.setEditable(true);
LOG.info("Prompt-Editor: Prompt-Datei gespeichert unter {}.", saved.absolutePath());
} else if (result instanceof PromptSaveResult.TargetDirectoryMissing missing) {
setStatus("Fehler: " + missing.message());
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen Ordner fehlt: {}", missing.message());
} else if (result instanceof PromptSaveResult.WriteFailed failed) {
setStatus("Fehler beim Schreiben: " + failed.message());
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen Schreibfehler: {}", failed.message());
} else if (result instanceof PromptSaveResult.AtomicMoveFailed atomicFailed) {
setStatus("Fehler: Atomares Speichern fehlgeschlagen (kein Fallback). " + atomicFailed.message());
LOG.warn("Prompt-Editor: Atomares Verschieben fehlgeschlagen: {}", atomicFailed.message());
}
}
void resetToDefault() {
String defaultContent = de.gecheckt.pdf.umbenenner.application.validation
.technicaltest.DefaultPromptTemplate.defaultContent(maxTitleLength);
textArea.setText(defaultContent);
textArea.setEditable(true);
saveButton.setDisable(false);
setStatus("Standard-Prompt-Inhalt in den Editor geladen (noch nicht gespeichert).");
LOG.info("Prompt-Editor: Standard-Prompt-Inhalt in TextArea geladen (nicht gespeichert).");
}
private void requestCreateDefault() {
createDefaultButton.setDisable(true);
setStatus("Lege Standard-Prompt-Datei an ...");
CorrectionSuggestion.CreatePromptFile suggestion = new CorrectionSuggestion.CreatePromptFile(
configuredPromptPath,
"Standard-Prompt-Datei anlegen",
maxTitleLength);
Thread worker = threadFactory.apply(() -> {
CorrectionOutcome outcome = promptEditorPort.createDefaultPromptIfMissing(suggestion);
fxDispatcher.accept(() -> applyCreateDefaultResult(outcome));
});
worker.start();
}
private void applyCreateDefaultResult(CorrectionOutcome outcome) {
createDefaultButton.setDisable(false);
if (outcome instanceof CorrectionOutcome.Applied applied) {
setStatus(applied.message() + " Lade Inhalt ...");
LOG.info("Prompt-Editor: Standard-Prompt-Datei angelegt. Lade neu.");
// Inhalt sofort neu laden
loadPromptAsync();
} else if (outcome instanceof CorrectionOutcome.Failed failed) {
setStatus("Fehler beim Anlegen der Standard-Prompt-Datei: " + failed.errorMessage());
LOG.warn("Prompt-Editor: Anlegen der Standard-Prompt-Datei fehlgeschlagen: {}", failed.errorMessage());
} else if (outcome instanceof CorrectionOutcome.NotAttempted notAttempted) {
setStatus("Aktion nicht verfügbar: " + notAttempted.reason());
LOG.warn("Prompt-Editor: Anlegen nicht versucht: {}", notAttempted.reason());
}
}
private void setStatus(String message) {
statusLabel.setText(message);
}
}
@@ -0,0 +1,474 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Separator;
import javafx.scene.control.Tab;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
/**
* Fünfter Haupt-Tab des JavaFX-Editorfensters: die Scheduler-Steuerungsansicht.
* <p>
* Zeigt den aktuellen Zustand des automatischen Schedulers und erlaubt dessen
* Steuerung über {@link SchedulerControlUseCase}. Der Tab-Inhalt wird im Sekundentakt
* durch {@link #updateStatus(SchedulerStatus)} aktualisiert, das von der zentralen
* {@link GuiStatusRefreshTimeline} aufgerufen wird.
*
* <h2>Bereiche</h2>
* <ul>
* <li><strong>Scheduler-Steuerung</strong>: Status-Anzeige ( Aktiv / Gestoppt),
* Start-/Stopp-Schaltflächen, Countdown bis zum nächsten Lauf,
* Letzter-Lauf-Info, Fehlermeldung und Intervall-Konfiguration.</li>
* </ul>
*
* <h2>Threading</h2>
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
* werden. Start-, Stopp- und Speichern-Aktionen werden auf einem dedizierten
* Hintergrund-Worker-Thread ({@code gui-scheduler-control}) ausgeführt.
*/
public final class GuiSchedulerTab {
private static final String HEADER_LABEL_STYLE = "-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #7f8c8d;";
private static final Logger LOG = LogManager.getLogger(GuiSchedulerTab.class);
private static final String TAB_TITLE = "Scheduler";
/** Mindestwert für das konfigurierbare Ausführungsintervall. */
static final int MIN_INTERVAL_SECONDS = 30;
private static final DateTimeFormatter TIME_FORMATTER =
DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault());
private final Tab tab = new Tab(TAB_TITLE);
// Not final: may be updated via onSchedulerAvailable after the tab was created without a use
// case (e.g., when auto-load initialises the scheduler after the workspace was already built).
// Declared volatile so worker-thread reads (executeStart/Stop) see the write from the FX thread.
private volatile Optional<SchedulerControlUseCase> schedulerUseCase;
private final Supplier<Boolean> isConfigDirty;
// -------------------------------------------------------------------------
// Bereich 1: Scheduler-Steuerung
// -------------------------------------------------------------------------
private final Label statusLabel = new Label("○ Gestoppt");
private final Button startButton = new Button("Scheduler starten");
private final Button stopButton = new Button("Scheduler stoppen");
private final Label nextTickLabel = new Label();
private final Label lastRunLabel = new Label("Noch kein Lauf in dieser Sitzung.");
private final Label sessionTotalsLabel = new Label();
private final Label lastErrorLabel = new Label();
private final TextField intervalField = new TextField();
private final Label intervalValidationLabel = new Label();
private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "gui-scheduler-control");
t.setDaemon(true);
return t;
});
/**
* Erstellt einen neuen Scheduler-Tab.
*
* @param schedulerUseCase optionaler Use Case zur Scheduler-Steuerung;
* {@code null} wird als leer behandelt
* @param isConfigDirty Supplier der {@code true} zurückgibt wenn der
* Konfigurationseditor ungespeicherte Änderungen hat;
* {@code null} wird als immer {@code false} behandelt
*/
public GuiSchedulerTab(
Optional<SchedulerControlUseCase> schedulerUseCase,
Supplier<Boolean> isConfigDirty) {
this.schedulerUseCase = Objects.requireNonNullElse(schedulerUseCase, Optional.empty());
this.isConfigDirty = isConfigDirty != null ? isConfigDirty : () -> false;
tab.setClosable(false);
buildUi();
applyInitialState();
}
/**
* Liefert den JavaFX-Tab-Knoten für den Einhang in das {@code TabPane}.
*
* @return Tab-Knoten; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Macht den Scheduler-Use-Case für diesen Tab verfügbar, nachdem er nach einem
* erfolgreichen Datei-Öffnen initialisiert wurde.
* <p>
* Wird vom Workspace auf dem JavaFX Application Thread aufgerufen, nachdem der
* {@link GuiApplicationContextInitializer} auf einem Hintergrund-Thread einen
* {@link SchedulerControlUseCase} geliefert hat. Hat keine Wirkung, wenn bereits
* ein Use Case vorhanden ist.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param useCase der neu initialisierte Use Case; darf nicht {@code null} sein
*/
public void onSchedulerAvailable(SchedulerControlUseCase useCase) {
if (schedulerUseCase.isPresent()) {
return;
}
schedulerUseCase = Optional.of(useCase);
intervalField.setText(String.valueOf(useCase.getIntervalSeconds()));
intervalField.setEditable(true);
intervalField.setDisable(false);
startButton.setDisable(false);
startButton.setTooltip(null);
}
/**
* Gibt den aktuell verdrahteten Scheduler-Use-Case zurück.
* <p>
* Wird von der zentralen Status-Refresh-Timeline benötigt, weil der Use Case
* erst nach erfolgreichem Datei-Öffnen verfügbar wird (z. B. durch Auto-Load
* der zuletzt geladenen Konfiguration) und damit nicht im
* unveränderlichen {@code GuiStartupContext} steht.
*
* @return aktueller Use Case oder {@code Optional.empty()} wenn keiner verdrahtet ist
*/
public Optional<SchedulerControlUseCase> currentSchedulerUseCase() {
return schedulerUseCase;
}
/**
* Aktualisiert alle Tab-Elemente anhand des aktuellen Scheduler-Status.
* <p>
* Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem
* JavaFX Application Thread aufgerufen. Implementiert alle in der Spezifikation
* definierten Button-Zustände, Label-Texte und Sichtbarkeitsregeln.
*
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
*/
public void updateStatus(SchedulerStatus status) {
updateStatusLabel(status);
updateButtons(status);
updateNextTickLabel(status);
updateLastRunLabel(status);
updateSessionTotalsLabel(status);
updateLastErrorLabel(status);
updateIntervalFieldEditability(status);
}
// -------------------------------------------------------------------------
// UI-Aufbau
// -------------------------------------------------------------------------
private void buildUi() {
VBox controlArea = buildControlArea();
tab.setContent(controlArea);
wireActions();
}
private VBox buildControlArea() {
statusLabel.setStyle(HEADER_LABEL_STYLE);
stopButton.setDisable(true);
HBox buttonBox = new HBox(10, startButton, stopButton);
nextTickLabel.setVisible(false);
nextTickLabel.setManaged(false);
lastRunLabel.setWrapText(true);
sessionTotalsLabel.setWrapText(true);
sessionTotalsLabel.setStyle("-fx-text-fill: #7f8c8d;");
sessionTotalsLabel.setVisible(false);
sessionTotalsLabel.setManaged(false);
lastErrorLabel.setStyle("-fx-text-fill: #c0392b;");
lastErrorLabel.setWrapText(true);
lastErrorLabel.setVisible(false);
lastErrorLabel.setManaged(false);
Label intervalLabel = new Label("Intervall (Sekunden):");
intervalField.setPrefColumnCount(10);
HBox intervalBox = new HBox(10, intervalLabel, intervalField);
intervalBox.setAlignment(Pos.CENTER_LEFT);
intervalValidationLabel.setStyle("-fx-text-fill: #c0392b; -fx-font-size: 11px;");
intervalValidationLabel.setWrapText(true);
intervalValidationLabel.setVisible(false);
intervalValidationLabel.setManaged(false);
VBox controlArea = new VBox(12,
statusLabel,
buttonBox,
nextTickLabel,
lastRunLabel,
sessionTotalsLabel,
lastErrorLabel,
new Separator(),
intervalBox,
intervalValidationLabel);
controlArea.setPadding(new Insets(16));
return controlArea;
}
private void wireActions() {
startButton.setOnAction(e -> executeStart());
stopButton.setOnAction(e -> executeStop());
intervalField.focusedProperty().addListener((obs, wasFocused, focused) -> {
if (!focused) {
validateAndSaveInterval();
}
});
}
private void applyInitialState() {
if (schedulerUseCase.isEmpty()) {
startButton.setDisable(true);
startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
stopButton.setDisable(true);
intervalField.setEditable(false);
intervalField.setDisable(true);
} else {
intervalField.setText(String.valueOf(schedulerUseCase.get().getIntervalSeconds()));
}
}
// -------------------------------------------------------------------------
// updateStatus-Hilfsmethoden
// -------------------------------------------------------------------------
private void updateStatusLabel(SchedulerStatus status) {
switch (status.state()) {
case STOPPED -> {
statusLabel.setText("○ Gestoppt");
statusLabel.setStyle(HEADER_LABEL_STYLE);
}
case STARTING -> {
statusLabel.setText("⟳ Wird gestartet…");
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #e67e22;");
}
case RUNNING_IDLE -> {
statusLabel.setText("● Aktiv");
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
}
case RUNNING_BATCH_ACTIVE -> {
statusLabel.setText("● Aktiv Lauf aktiv");
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
}
case STOPPING_BATCH_ACTIVE -> {
statusLabel.setText("○ Gestoppt aktueller Lauf läuft noch");
statusLabel.setStyle(HEADER_LABEL_STYLE);
}
}
}
private void updateButtons(SchedulerStatus status) {
boolean noUseCase = schedulerUseCase.isEmpty();
boolean configDirty = Boolean.TRUE.equals(isConfigDirty.get());
switch (status.state()) {
case STOPPED -> {
stopButton.setDisable(true);
if (noUseCase) {
startButton.setDisable(true);
startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
} else if (configDirty) {
startButton.setDisable(true);
startButton.setTooltip(new Tooltip("Bitte Konfiguration speichern"));
} else {
startButton.setDisable(false);
startButton.setTooltip(null);
}
}
case STARTING -> {
startButton.setDisable(true);
stopButton.setDisable(true);
}
case RUNNING_IDLE, RUNNING_BATCH_ACTIVE -> {
startButton.setDisable(true);
startButton.setTooltip(null);
stopButton.setDisable(false);
}
case STOPPING_BATCH_ACTIVE -> {
startButton.setDisable(true);
stopButton.setDisable(true);
}
}
}
private void updateNextTickLabel(SchedulerStatus status) {
if (status.state() == SchedulerState.RUNNING_IDLE && status.nextTickAt().isPresent()) {
long remaining = ChronoUnit.SECONDS.between(Instant.now(), status.nextTickAt().get());
if (remaining > 0) {
long minutes = remaining / 60;
long seconds = remaining % 60;
nextTickLabel.setText(String.format("Nächster Lauf in: %02d:%02d", minutes, seconds));
} else {
nextTickLabel.setText("Lauf steht bevor…");
}
nextTickLabel.setVisible(true);
nextTickLabel.setManaged(true);
} else {
nextTickLabel.setVisible(false);
nextTickLabel.setManaged(false);
}
}
private void updateLastRunLabel(SchedulerStatus status) {
if (status.lastRunEndedAt().isPresent() && status.lastRunSummary().isPresent()) {
Instant endedAt = status.lastRunEndedAt().get();
RunSummary summary = status.lastRunSummary().get();
String timeStr = TIME_FORMATTER.format(endedAt);
boolean noDocuments = summary.successCount() == 0
&& summary.failedCount() == 0;
if (noDocuments) {
lastRunLabel.setText("Letzter Lauf: " + timeStr + " keine neuen Dokumente");
} else {
lastRunLabel.setText("Letzter Lauf: " + timeStr + " "
+ summary.successCount() + " verarbeitet, "
+ summary.failedCount() + " Fehler");
}
} else {
lastRunLabel.setText("Noch kein Lauf in dieser Sitzung.");
}
}
private void updateSessionTotalsLabel(SchedulerStatus status) {
Optional<SchedulerSessionTotals> totals = status.sessionTotals();
if (totals.isPresent()) {
SchedulerSessionTotals t = totals.get();
sessionTotalsLabel.setText("Seit Scheduler-Start: "
+ t.successCount() + " verarbeitet, "
+ t.failedCount() + " Fehler");
sessionTotalsLabel.setVisible(true);
sessionTotalsLabel.setManaged(true);
} else {
sessionTotalsLabel.setVisible(false);
sessionTotalsLabel.setManaged(false);
}
}
private void updateLastErrorLabel(SchedulerStatus status) {
Optional<String> lastError = status.lastError();
if (lastError.isPresent() && !lastError.get().isBlank()) {
lastErrorLabel.setText("Fehler: " + lastError.get());
lastErrorLabel.setVisible(true);
lastErrorLabel.setManaged(true);
} else {
lastErrorLabel.setVisible(false);
lastErrorLabel.setManaged(false);
}
}
private void updateIntervalFieldEditability(SchedulerStatus status) {
boolean editable = status.state() == SchedulerState.STOPPED
&& schedulerUseCase.isPresent()
&& !Boolean.TRUE.equals(isConfigDirty.get());
intervalField.setEditable(editable);
intervalField.setDisable(!editable);
}
// -------------------------------------------------------------------------
// Aktions-Handler
// -------------------------------------------------------------------------
private void executeStart() {
LOG.info("GUI: Scheduler-Start angefordert.");
startButton.setDisable(true);
stopButton.setDisable(true);
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
try {
uc.start();
LOG.info("GUI: Scheduler erfolgreich gestartet.");
} catch (SchedulerStartException e) {
LOG.warn("GUI: Scheduler-Start fehlgeschlagen: {}", e.getMessage());
Platform.runLater(() -> showStartErrorAlert(e.getMessage()));
} catch (RuntimeException e) {
LOG.error("GUI: Unerwarteter Fehler beim Starten des Schedulers.", e);
Platform.runLater(() -> showStartErrorAlert("Unerwarteter Fehler: " + e.getMessage()));
}
}));
}
private void executeStop() {
LOG.info("GUI: Scheduler-Stopp angefordert.");
startButton.setDisable(true);
stopButton.setDisable(true);
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
try {
uc.stop();
LOG.info("GUI: Scheduler gestoppt.");
} catch (RuntimeException e) {
LOG.error("GUI: Unerwarteter Fehler beim Stoppen des Schedulers.", e);
}
}));
}
private void validateAndSaveInterval() {
String text = intervalField.getText() == null ? "" : intervalField.getText().trim();
try {
int value = Integer.parseInt(text);
if (value < MIN_INTERVAL_SECONDS) {
showIntervalValidationError(
"Mindestintervall ist " + MIN_INTERVAL_SECONDS + " Sekunden.");
} else {
hideIntervalValidationError();
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
try {
uc.saveIntervalSeconds(value);
} catch (RuntimeException e) {
LOG.warn("GUI: Fehler beim Speichern des Scheduler-Intervalls: {}", e.getMessage());
Platform.runLater(() -> showIntervalValidationError(
"Speichern fehlgeschlagen: " + e.getMessage()));
}
}));
}
} catch (NumberFormatException e) {
showIntervalValidationError("Bitte eine ganze Zahl eingeben.");
}
}
private void showIntervalValidationError(String message) {
intervalValidationLabel.setText(message);
intervalValidationLabel.setVisible(true);
intervalValidationLabel.setManaged(true);
}
private void hideIntervalValidationError() {
intervalValidationLabel.setVisible(false);
intervalValidationLabel.setManaged(false);
}
private static void showStartErrorAlert(String message) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Scheduler-Start fehlgeschlagen");
alert.setHeaderText("Der Scheduler konnte nicht gestartet werden.");
alert.setContentText(message != null ? message : "Unbekannter Fehler.");
alert.showAndWait();
}
}
@@ -11,9 +11,16 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
@@ -44,7 +51,29 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
* folder for documents that have not yet been successfully processed, and
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
* context for documents that were skipped in the current run.
* context for documents that were skipped in the current run, the resolved application
* version string that the status bar displays at the bottom of the main window, and the
* optional {@link SchedulerControlUseCase} for controlling the automatic scheduler.
* <p>
* The optional {@code applicationContextError} carries a human-readable German error
* message when the bootstrap-side application run context could not be initialised at
* startup (e.g., invalid or incomplete configuration). An empty value signals that the
* run context was built successfully and batch runs can be launched immediately.
* <p>
* The optional {@code schedulerControlUseCase} is present when the automatic scheduler
* was successfully wired at startup. An empty value means scheduler control is not
* available in this startup context (e.g., no valid configuration was loaded at startup).
* <p>
* The optional {@code configurationFileLockPort} is present when the GUI can acquire an
* OS-level exclusive lock on the configuration file before a manual batch run. When present,
* it is acquired by the {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}
* on the worker thread before each run and released in a finally block. An empty value means
* no locking is performed (e.g., no valid configuration was loaded at startup, or locking is
* not required in this context).
* <p>
* The {@code applicationContextInitializer} is invoked on a background thread each time the
* workspace loads a configuration file (auto-load at startup and manual open). Bootstrap
* provides an implementation that builds the application run context and wires the scheduler.
* <p>
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
* know about provider-specific HTTP details or adapter wiring.
@@ -65,7 +94,23 @@ public record GuiStartupContext(
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort,
Optional<String> applicationContextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase,
Optional<ConfigurationFileLockPort> configurationFileLockPort,
GuiApplicationContextInitializer applicationContextInitializer,
Optional<GuiModelPriceManagementPort> modelPriceManagementPort) {
private static final String NO_PROMPT_PORT_MSG = "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.";
private static final String NO_PORT_MSG = "Kein Port in diesem Startkontext.";
/**
* Creates a fully wired startup context.
@@ -92,10 +137,19 @@ public record GuiStartupContext(
* must not be {@code null}
* @param historicalDocumentContextPort bridge that resolves the historical processing context
* for skipped documents; must not be {@code null}
* @param applicationVersion resolved application version string shown in the status
* bar; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
* {@code null} sein
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
* darf nicht {@code null} sein
* @param applicationContextError optional error message when the application run context
* could not be initialised at startup; {@code null} becomes empty
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
startupNotice = startupNotice == null ? Optional.empty() : startupNotice;
startupNotice = Objects.requireNonNullElse(startupNotice, Optional.empty());
applicationContextError = Objects.requireNonNullElse(applicationContextError, Optional.empty());
configurationFileLoader = Objects.requireNonNull(configurationFileLoader,
"configurationFileLoader must not be null");
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
@@ -124,6 +178,167 @@ public record GuiStartupContext(
"manualFileCopyPort must not be null");
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
"historicalDocumentContextPort must not be null");
// Null-Fallback für Testumgebungen ohne gepacktes JAR
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
"historyOverviewPort must not be null");
historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
"historyDetailsPort must not be null");
historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
"historyResetDocumentStatusPort must not be null");
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
"deleteDocumentHistoryPort must not be null");
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
"promptEditorPortFactory must not be null");
createNewDatabasePort = Objects.requireNonNull(createNewDatabasePort,
"createNewDatabasePort must not be null");
schedulerControlUseCase = Objects.requireNonNullElse(schedulerControlUseCase, Optional.empty());
configurationFileLockPort = Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
applicationContextInitializer = applicationContextInitializer == null
? GuiApplicationContextInitializer.noOp() : applicationContextInitializer;
modelPriceManagementPort = Objects.requireNonNullElse(modelPriceManagementPort, Optional.empty());
}
/**
* Backward-compatible constructor that fills {@code schedulerControlUseCase} with
* {@link Optional#empty()}.
* <p>
* Preserves existing callers that were written before the scheduler was added.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets document status; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file; must not be {@code null}
* @param manualFileCopyPort bridge that copies a source file; must not be {@code null}
* @param historicalDocumentContextPort bridge for historical processing context; must not be {@code null}
* @param applicationVersion resolved application version string; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; must not be {@code null}
* @param historyOverviewPort bridge for history overview; must not be {@code null}
* @param historyDetailsPort bridge for history details; must not be {@code null}
* @param historyResetDocumentStatusPort bridge for history reset; must not be {@code null}
* @param deleteDocumentHistoryPort bridge for history deletion; must not be {@code null}
* @param promptEditorPortFactory factory for prompt editor ports; must not be {@code null}
* @param createNewDatabasePort bridge for new database creation; must not be {@code null}
* @param applicationContextError optional error from context init; {@code null} becomes empty
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort,
Optional<String> applicationContextError) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, manualFileRenamePort, manualFileCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, Optional.empty(), Optional.empty(),
GuiApplicationContextInitializer.noOp(), Optional.empty());
}
/**
* Backward-compatible constructor that fills {@code configurationFileLockPort} with
* {@link Optional#empty()}.
* <p>
* Preserves existing callers that were written before the configuration file lock port
* was added.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets document status; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file; must not be {@code null}
* @param manualFileCopyPort bridge that copies a source file; must not be {@code null}
* @param historicalDocumentContextPort bridge for historical processing context; must not be {@code null}
* @param applicationVersion resolved application version string; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; must not be {@code null}
* @param historyOverviewPort bridge for history overview; must not be {@code null}
* @param historyDetailsPort bridge for history details; must not be {@code null}
* @param historyResetDocumentStatusPort bridge for history reset; must not be {@code null}
* @param deleteDocumentHistoryPort bridge for history deletion; must not be {@code null}
* @param promptEditorPortFactory factory for prompt editor ports; must not be {@code null}
* @param createNewDatabasePort bridge for new database creation; must not be {@code null}
* @param applicationContextError optional error from context init; {@code null} becomes empty
* @param schedulerControlUseCase optional scheduler control use case; {@code null} becomes empty
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort,
Optional<String> applicationContextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, manualFileRenamePort, manualFileCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, schedulerControlUseCase, Optional.empty(),
GuiApplicationContextInitializer.noOp(), Optional.empty());
}
/**
@@ -165,7 +380,10 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(), Optional.empty());
}
/**
@@ -201,7 +419,10 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(), Optional.empty());
}
/**
@@ -237,7 +458,10 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(), Optional.empty());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -307,28 +531,29 @@ public record GuiStartupContext(
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
noOpPathCheckPort,
noOpTestService);
noOpTestService,
() -> java.util.Optional.empty());
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreateDirectory suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.PrepareSqlitePath suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
}
};
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
@@ -351,6 +576,83 @@ public record GuiStartupContext(
rejectingResetPort(),
rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
noOpHistoricalDocumentContextPort(),
"dev",
noOpPromptEditorPort(),
noOpHistoryOverviewPort(),
noOpHistoryDetailsPort(),
noOpHistoryResetPort(),
noOpDeleteHistoryPort(),
noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(),
Optional.empty());
}
/**
* Liefert einen ablehnenden {@link GuiCreateNewDatabasePort}, der jede Anlage
* sofort als Fehler zurückgibt. Wird verwendet, wenn kein Bootstrap-seitig
* verdrahteter Port vorliegt (z. B. in Tests oder vor dem Laden einer
* Konfiguration).
*
* @return ein ablehnender Port; nie {@code null}
*/
private static GuiCreateNewDatabasePort rejectingCreateNewDatabasePort() {
return (configFilePath, targetFile) -> new de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed(
de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
"Kein DB-Anlage-Port in diesem Startkontext verfügbar.",
null);
}
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
return path -> noOpPromptEditorPort();
}
private static GuiPromptEditorPort noOpPromptEditorPort() {
return new GuiPromptEditorPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
"NO_OP", NO_PROMPT_PORT_MSG);
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
NO_PROMPT_PORT_MSG, null);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDefaultPromptIfMissing(
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(
suggestion, NO_PROMPT_PORT_MSG);
}
};
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort
noOpHistoryOverviewPort() {
return (configFilePath, query) -> new de.gecheckt.pdf.umbenenner.application.usecase
.DefaultHistoryOverviewUseCase.HistoryOverviewResult(java.util.List.of(), false);
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort
noOpHistoryDetailsPort() {
return (configFilePath, fingerprint) -> java.util.Optional.empty();
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort
noOpHistoryResetPort() {
return (configFilePath, fingerprint) -> { /* kein Reset in diesem Startkontext */ };
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort
noOpDeleteHistoryPort() {
return (configFilePath, fingerprint) -> { /* kein Löschen in diesem Startkontext */ };
}
}
@@ -0,0 +1,199 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.Separator;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
/**
* Permanente Statuszeile am unteren Rand des Hauptfensters.
* <p>
* Die Statuszeile zeigt immer drei Segmente:
* <ul>
* <li><b>Links:</b> Anwendungsversion im Format {@code V<version>}, z.&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 {
private static final String LABEL_STYLE = "-fx-font-size: 11px; -fx-text-fill: #555555;";
/** Anzeigetext wenn keine Konfiguration geladen ist. */
static final String KEIN_PROFIL_TEXT = "Kein Profil geladen";
/** Präfix vor der Versionsnummer in der linken Statuszeilen-Zelle. */
private static final String VERSION_PREFIX = "V";
private static final AiProviderFamilyStringConverter PROVIDER_CONVERTER =
new AiProviderFamilyStringConverter();
private final String applicationVersion;
private final BorderPane root;
private final Label versionLabel;
private final Label providerLabel;
private final Label configPathLabel;
/**
* Erstellt eine neue Statuszeile mit der angegebenen Anwendungsversion.
*
* @param applicationVersion die aufgelöste Versionsnummer; {@code null} oder leer führt zum
* Fallback {@code "dev"}
*/
public GuiStatusBar(String applicationVersion) {
this.applicationVersion = (applicationVersion == null || applicationVersion.isBlank())
? "dev"
: applicationVersion;
// Linkes Segment: Versionsanzeige
this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion);
this.versionLabel.setStyle(LABEL_STYLE);
// Mittleres Segment: Provider und Modell
this.providerLabel = new Label(KEIN_PROFIL_TEXT);
this.providerLabel.setStyle(LABEL_STYLE);
this.providerLabel.setAlignment(Pos.CENTER);
// Rechtes Segment: Konfigurationspfad
this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
this.configPathLabel.setStyle(LABEL_STYLE);
this.configPathLabel.setAlignment(Pos.CENTER_RIGHT);
// Abstandhalter zwischen den Segmenten
Region leftSpacer = new Region();
Region rightSpacer = new Region();
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
HBox content = new HBox(16,
versionLabel, leftSpacer,
providerLabel, rightSpacer,
configPathLabel);
content.setAlignment(Pos.CENTER_LEFT);
content.setPadding(new Insets(4, 12, 4, 12));
content.setStyle("-fx-background-color: #f5f5f5;");
Separator topSeparator = new Separator();
this.root = new BorderPane();
this.root.setTop(topSeparator);
this.root.setCenter(content);
}
/**
* Gibt den Wurzelknoten der Statuszeile zurück, der in das Hauptfenster eingebettet wird.
*
* @return der Wurzelknoten; nie {@code null}
*/
public BorderPane root() {
return root;
}
/**
* Aktualisiert die Statuszeile anhand des aktuellen Editor-Zustands.
* <p>
* Ist kein Dateisnapshot vorhanden, wird {@link #clearConfiguration()} ausgeführt.
* Andernfalls werden Provider, Modell und Konfigurationspfad aus dem Zustand ermittelt
* und angezeigt.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param state der aktuelle Editor-Zustand; darf nicht {@code null} sein
*/
public void applyEditorState(GuiConfigurationEditorState state) {
if (state == null || !state.hasLoadedFileSnapshot()) {
clearConfiguration();
return;
}
String configPath = state.configurationPathText();
String providerText = resolveProviderText(state);
providerLabel.setText(providerText);
configPathLabel.setText(configPath.isBlank() ? KEIN_PROFIL_TEXT : configPath);
}
/**
* Setzt Mitte und Rechts der Statuszeile auf den Text {@link #KEIN_PROFIL_TEXT} zurück.
* <p>
* Die Versionsanzeige bleibt unverändert.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clearConfiguration() {
providerLabel.setText(KEIN_PROFIL_TEXT);
configPathLabel.setText(KEIN_PROFIL_TEXT);
}
/**
* Gibt den aktuell angezeigten Versionstext zurück (inkl. Präfix {@code V}).
* <p>
* Für Tests zugänglich.
*
* @return der angezeigte Versionstext; nie {@code null}
*/
String versionText() {
return versionLabel.getText();
}
/**
* Gibt den aktuell angezeigten Provider-Text zurück.
* <p>
* Für Tests zugänglich.
*
* @return der angezeigte Provider-Text; nie {@code null}
*/
String providerText() {
return providerLabel.getText();
}
/**
* Gibt den aktuell angezeigten Konfigurationspfad-Text zurück.
* <p>
* Für Tests zugänglich.
*
* @return der angezeigte Konfigurationspfad-Text; nie {@code null}
*/
String configPathText() {
return configPathLabel.getText();
}
/**
* Ermittelt den anzuzeigenden Provider-Text aus dem Editor-Zustand.
* <p>
* Das Format ist: {@code Provider: <AnzeigeName> · <Modellname>}, wobei der Modellname
* weggelassen wird, wenn er leer ist.
*
* @param state der Editor-Zustand; darf nicht {@code null} sein
* @return der formatierte Provider-Text; nie {@code null}
*/
private static String resolveProviderText(GuiConfigurationEditorState state) {
String activeIdentifier = state.values().activeProviderFamily();
if (activeIdentifier == null || activeIdentifier.isBlank()) {
return KEIN_PROFIL_TEXT;
}
AiProviderFamily family = AiProviderFamily.fromIdentifier(activeIdentifier).orElse(null);
if (family == null) {
return KEIN_PROFIL_TEXT;
}
String displayName = PROVIDER_CONVERTER.toString(family);
GuiProviderConfigurationState providerState = state.values().providerConfiguration(family);
String model = providerState != null ? providerState.model() : "";
if (model == null || model.isBlank()) {
return "Provider: " + displayName;
}
return "Provider: " + displayName + " · " + model;
}
}
@@ -0,0 +1,68 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.util.Duration;
/**
* Zentrale Status-Refresh-Timeline für die GUI.
* <p>
* Startet eine JavaFX-{@link Timeline}, die im Sekundentakt einen Callback aufruft.
* Der Callback liest den aktuellen Scheduler-Status und aktualisiert alle betroffenen
* Tabs (Batch-Tab, Konfig-Tab, Scheduler-Tab) auf dem JavaFX Application Thread.
* <p>
* Die Timeline wird beim Aufbau der Haupt-GUI gestartet und beim Beenden der
* Anwendung gestoppt. Sie läuft unabhängig davon, welcher Tab gerade sichtbar ist.
* <p>
* Wenn kein {@link SchedulerControlUseCase} vorhanden ist, wird der Callback trotzdem
* aufgerufen der Aufrufer entscheidet, wie er das leere Optional behandelt.
*/
public final class GuiStatusRefreshTimeline {
private final Timeline timeline;
/**
* Erzeugt eine neue Status-Refresh-Timeline.
* <p>
* Die Timeline ist nach der Konstruktion noch nicht aktiv; {@link #start()} muss
* explizit aufgerufen werden.
*
* @param schedulerControlUseCase optionaler Scheduler-Control-Use-Case;
* {@code null} wird als leer behandelt
* @param onRefresh Callback der bei jedem Tick auf dem JavaFX Application
* Thread aufgerufen wird; darf nicht {@code null} sein
*/
public GuiStatusRefreshTimeline(
Optional<SchedulerControlUseCase> schedulerControlUseCase,
Runnable onRefresh) {
Objects.requireNonNull(onRefresh, "onRefresh must not be null");
this.timeline = new Timeline(
new KeyFrame(Duration.seconds(1), e -> onRefresh.run()));
this.timeline.setCycleCount(Animation.INDEFINITE);
}
/**
* Startet die Status-Refresh-Timeline.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
* Mehrfache Aufrufe sind unschädlich.
*/
public void start() {
timeline.play();
}
/**
* Stoppt die Status-Refresh-Timeline.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
* Mehrfache Aufrufe sind unschädlich.
*/
public void stop() {
timeline.stop();
}
}
@@ -64,6 +64,7 @@ public final class GuiTechnicalTestCoordinator {
private final TechnicalTestOrchestrator orchestrator;
private final Supplier<EditorValidationInput> inputProvider;
private final Supplier<String> configFilePathProvider;
private final Supplier<String> logDirectoryProvider;
private final List<GuiMessageEntry> pendingMessages;
private final Consumer<TechnicalTestReport> postResultCallback;
@@ -89,6 +90,9 @@ public final class GuiTechnicalTestCoordinator {
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
* darf nicht {@code null} sein
* @param logDirectoryProvider Lieferant des konfigurierten {@code log.directory}-Rohwerts;
* gibt eine leere Zeichenkette zurück wenn kein Wert konfiguriert ist;
* darf nicht {@code null} sein
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
* @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; darf nicht {@code null} sein
* @throws NullPointerException wenn einer der Parameter {@code null} ist
@@ -96,11 +100,13 @@ public final class GuiTechnicalTestCoordinator {
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
Supplier<EditorValidationInput> inputProvider,
Supplier<String> configFilePathProvider,
Supplier<String> logDirectoryProvider,
List<GuiMessageEntry> pendingMessages,
Consumer<TechnicalTestReport> postResultCallback) {
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider must not be null");
this.configFilePathProvider = Objects.requireNonNull(configFilePathProvider, "configFilePathProvider must not be null");
this.logDirectoryProvider = Objects.requireNonNull(logDirectoryProvider, "logDirectoryProvider must not be null");
this.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages must not be null");
this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
this.testThreadFactory = task -> {
@@ -134,7 +140,8 @@ public final class GuiTechnicalTestCoordinator {
pendingMessages.clear();
EditorValidationInput input = inputProvider.get();
String configFilePath = configFilePathProvider.get();
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath);
String logDirectory = logDirectoryProvider.get();
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath, logDirectory);
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
@@ -234,6 +241,7 @@ public final class GuiTechnicalTestCoordinator {
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
case LOG_DIRECTORY_USABLE -> "Log-Verzeichnis beschreibbar";
};
}
@@ -0,0 +1,296 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
/**
* Zentrale Konstantenklasse für alle Tooltip-Texte der GUI.
* <p>
* Diese Klasse ist die einzige autoritative Quelle für Tooltip-Beschriftungen aller
* interaktiven Elemente in der Desktop-Oberfläche. Alle Tooltip-Strings werden hier
* definiert und von den jeweiligen UI-Klassen referenziert. Streustrings im
* UI-Code sind unzulässig.
* <p>
* Tooltip-Texte für Status-Icons werden <em>nicht</em> hier gepflegt sie stammen
* ausschließlich aus {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation},
* die die autoritative Quelle für alle statusbezogenen Darstellungsinformationen ist.
* <p>
* Alle Texte sind deutschsprachig gemäß Spezifikation.
* Diese Klasse enthält keine JavaFX-Typen und ist nicht instanziierbar.
*/
public final class GuiTooltipTexts {
// -------------------------------------------------------------------------
// Toolbar-Buttons
// -------------------------------------------------------------------------
/** Tooltip für den Button „Neu". */
public static final String TOOLBAR_NEU =
"Neue Konfiguration erstellen.";
/** Tooltip für den Button „Öffnen". */
public static final String TOOLBAR_OEFFNEN =
"Bestehende Konfigurationsdatei (.properties) öffnen.";
/** Tooltip für den Button „Speichern". */
public static final String TOOLBAR_SPEICHERN =
"Aktuelle Konfiguration speichern.";
/** Tooltip für den Button „Speichern unter". */
public static final String TOOLBAR_SPEICHERN_UNTER =
"Konfiguration unter neuem Dateipfad speichern.";
/** Tooltip für den Button „Validieren". */
public static final String TOOLBAR_VALIDIEREN =
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.";
/** Tooltip für den Button „Technische Tests ausführen". */
public static final String TOOLBAR_TECHNISCHE_TESTS =
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.";
// -------------------------------------------------------------------------
// Konfigurationstab Pfade
// -------------------------------------------------------------------------
/** Tooltip für das Eingabefeld „Quellordner". */
public static final String PFADE_QUELLORDNER =
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.";
/** Tooltip für das Eingabefeld „Zielordner". */
public static final String PFADE_ZIELORDNER =
"Ordner für die umbenannten Kopien.";
/** Tooltip für das Eingabefeld „SQLite-Datei". */
public static final String PFADE_SQLITE =
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.";
/** Tooltip für das Eingabefeld „Prompt-Datei". */
public static final String PFADE_PROMPT =
"Externe Textdatei mit den KI-Anweisungen.";
/** Tooltip für das Eingabefeld „Lock-Datei". */
public static final String PFADE_LOCK_DATEI =
"Pfad zur Lock-Datei, die parallele Instanzen verhindert (optional).";
/** Tooltip für das Eingabefeld „Log-Verzeichnis". */
public static final String PFADE_LOG_VERZEICHNIS =
"Verzeichnis für Log-Dateien. Leer = Standardverzeichnis logs/ im Programmverzeichnis.";
// -------------------------------------------------------------------------
// Konfigurationstab Provider
// -------------------------------------------------------------------------
/** Tooltip für die Provider-ComboBox. */
public static final String PROVIDER_COMBOBOX =
"Der KI-Dienst, der die Dateinamen generiert.";
/** Tooltip für das Modell-Eingabefeld (ComboBox oder manuelles TextField). */
public static final String PROVIDER_MODELL =
"Das konkrete Sprachmodell des gewählten Providers.";
/** Tooltip für das Eingabefeld „Basis-URL". */
public static final String PROVIDER_BASIS_URL =
"Basis-URL des KI-Dienstes (z. B. https://api.openai.com/v1).";
/** Tooltip für das Eingabefeld „Timeout". */
public static final String PROVIDER_TIMEOUT =
"Zeitlimit für KI-Anfragen in Sekunden.";
/** Tooltip für das Eingabefeld „API-Key". */
public static final String PROVIDER_API_KEY =
"API-Schlüssel für den konfigurierten KI-Dienst. Umgebungsvariable hat Vorrang.";
// -------------------------------------------------------------------------
// Konfigurationstab Verarbeitungslimits
// -------------------------------------------------------------------------
/** Tooltip für das Eingabefeld „max.text.characters". */
public static final String LIMITS_MAX_TEXT_CHARACTERS =
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.";
/** Tooltip für das Eingabefeld „max.pages". */
public static final String LIMITS_MAX_PAGES =
"Maximale Seitenzahl, die aus einem PDF gelesen wird.";
/** Tooltip für das Eingabefeld „max.title.length". */
public static final String LIMITS_MAX_TITLE_LENGTH =
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10120.";
/** Tooltip für das Eingabefeld „max.retries.transient". */
public static final String LIMITS_MAX_RETRIES =
"Maximale Anzahl transienter Wiederholversuche je Dokument (Ganzzahl ≥ 1).";
/** Tooltip für das Eingabefeld „Log-Level". */
public static final String LIMITS_LOG_LEVEL =
"Log-Detailstufe (z. B. INFO, DEBUG, WARN). Leer = Standardwert INFO.";
/** Tooltip für die Checkbox „Sensible KI-Ausgabe". */
public static final String LIMITS_SENSIBLE_KI_AUSGABE =
"Vollständige KI-Antworten in die Log-Datei schreiben (nur für Diagnosezwecke empfohlen).";
// -------------------------------------------------------------------------
// Verarbeitungslauf-Tab Dateiname-Editor
// -------------------------------------------------------------------------
/** Tooltip für das Dateiname-Textfeld im Dateiname-Editor. */
public static final String DATEINAME_TEXTFELD =
"Dateiname bearbeiten. Format: JJJJ-MM-TT - Titel.pdf";
/** Tooltip für den Button „Dateiname übernehmen". */
public static final String DATEINAME_UEBERNEHMEN =
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.";
/** Tooltip für den Button „Zurücksetzen auf KI-Vorschlag". */
public static final String DATEINAME_ZURUECKSETZEN =
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.";
// -------------------------------------------------------------------------
// Verarbeitungslauf-Tab Laufsteuerung und Tabelle
// -------------------------------------------------------------------------
/** Tooltip für den Button „Starten". */
public static final String BATCHRUN_STARTEN =
"Verarbeitungslauf starten: alle ausstehenden PDF-Dateien aus dem Quellordner verarbeiten.";
/** Tooltip für den Button „Abbrechen". */
public static final String BATCHRUN_ABBRECHEN =
"Laufenden Verarbeitungslauf abbrechen. Bereits abgeschlossene Dateien bleiben gespeichert.";
/** Tooltip für den Button „Erneut verarbeiten". */
public static final String BATCHRUN_ERNEUT_VERARBEITEN =
"Markierte Einträge erneut zur Verarbeitung freigeben (setzt Status auf READY_FOR_AI).";
/** Tooltip für den Button „Status zurücksetzen" im Verarbeitungslauf-Tab. */
public static final String BATCHRUN_STATUS_ZURUECKSETZEN =
"Status der markierten Einträge zurücksetzen, damit sie beim nächsten Lauf verarbeitet werden.";
/** Tooltip für die Master-Checkbox im Tabellenkopf des Verarbeitungslauf-Tabs. */
public static final String BATCHRUN_MASTER_CHECKBOX =
"Alle sichtbaren Einträge markieren oder Markierung aufheben.";
/** Tooltip für den Meldungsbereich im Verarbeitungslauf-Tab. */
public static final String BATCHRUN_MESSAGE_AREA =
"Statusmeldungen und Fortschrittsinformationen des aktuellen Verarbeitungslaufs.";
/** Tooltip für den Navigations-Button „Vorherige Seite" in der PDF-Vorschau. */
public static final String PREVIEW_VORHERIGE_SEITE =
"Vorherige Seite der Vorschau anzeigen.";
/** Tooltip für den Navigations-Button „Nächste Seite" in der PDF-Vorschau. */
public static final String PREVIEW_NAECHSTE_SEITE =
"Nächste Seite der Vorschau anzeigen.";
/** Tooltip für Spalte „Status" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_STATUS =
"Verarbeitungsergebnis: Erfolg, Fehler oder übersprungen.";
/** Tooltip für Spalte „Originaldateiname" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_ORIGINALDATEINAME =
"Ursprünglicher Dateiname der verarbeiteten PDF-Datei.";
/** Tooltip für Spalte „Neuer Dateiname" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_NEUER_DATEINAME =
"Von der KI vorgeschlagener, normierter Dateiname.";
/** Tooltip für Spalte „Datum" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_DATUM =
"Datum des Dokuments laut KI-Analyse.";
/** Tooltip für Spalte „Dauer" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_DAUER =
"Verarbeitungsdauer für diese Datei.";
// -------------------------------------------------------------------------
// Verlauf-Tab Detailbereich
// -------------------------------------------------------------------------
/** Tooltip für den KI-Begründungs-Bereich im Verlauf-Tab. */
public static final String VERLAUF_REASONING_AREA =
"KI-Begründung des ausgewählten Verarbeitungsversuchs.";
/** Tooltip für den Fehlerursachen-Bereich im Verlauf-Tab. */
public static final String VERLAUF_FAILURE_AREA =
"Fehlermeldung des letzten Fehler-Versuchs für dieses Dokument.";
/** Tooltip für Spalte „Status" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_STATUS =
"Aktueller Gesamtstatus des Dokuments.";
/** Tooltip für Spalte „Quelldatei" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_QUELLDATEI =
"Ursprünglicher Dateiname der PDF-Quelldatei.";
/** Tooltip für Spalte „Zieldatei" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_ZIELDATEI =
"Vom System erzeugter, normierter Dateiname im Zielordner.";
/** Tooltip für Spalte „Letzter Versuch" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_LETZTER_VERSUCH =
"Zeitpunkt des zuletzt abgeschlossenen Verarbeitungsversuchs.";
/** Tooltip für Spalte „Versuche" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_VERSUCHE =
"Gesamtanzahl der Verarbeitungsversuche für dieses Dokument.";
/** Tooltip für Spalte „#" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_NR =
"Laufende Nummer des Verarbeitungsversuchs.";
/** Tooltip für Spalte „Datum" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_DATUM =
"Endzeitpunkt dieses Verarbeitungsversuchs.";
/** Tooltip für Spalte „Status" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_STATUS =
"Ergebnis dieses Verarbeitungsversuchs.";
/** Tooltip für Spalte „Provider" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_PROVIDER =
"KI-Provider, der für diesen Versuch verwendet wurde.";
/** Tooltip für Spalte „Modell" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_MODELL =
"Konkretes Sprachmodell, das für diesen Versuch verwendet wurde.";
/** Tooltip für Spalte „Vorgeschlagener Name" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_VORGESCHLAGENER_NAME =
"Vom System erzeugter Zieldateiname für diesen Versuch.";
// -------------------------------------------------------------------------
// Konfigurations-Tab Meldungsbereich und Modell-Neu-Laden
// -------------------------------------------------------------------------
/** Tooltip für den Button „Meldungen leeren". */
public static final String TOOLBAR_MELDUNGEN_LEEREN =
"Alle Meldungen im Meldungsbereich entfernen.";
/** Tooltip für den Button „Modelle neu laden". */
public static final String PROVIDER_MODELLE_NEU_LADEN =
"Verfügbare Modelle vom konfigurierten Provider neu abrufen.";
/** Tooltip für den Ordner-/Datei-Browser-Button. */
public static final String PFADE_BROWSER_BUTTON =
"Ordner oder Datei über den Datei-Dialog auswählen.";
// -------------------------------------------------------------------------
// Prompt-Tab Textbereich
// -------------------------------------------------------------------------
/** Tooltip für den Prompt-Textbereich im Prompt-Editor-Tab. */
public static final String PROMPT_TEXTAREA =
"KI-Anweisungstext. Dieser Prompt wird bei jedem Verarbeitungsversuch an das Sprachmodell gesendet.";
/** Tooltip für den Button „Speichern" im Prompt-Editor-Tab. */
public static final String PROMPT_SPEICHERN =
"Prompt-Datei speichern (atomar, UTF-8).";
/** Tooltip für den Button „Auf Standard zurücksetzen" im Prompt-Editor-Tab. */
public static final String PROMPT_ZURUECKSETZEN =
"Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern.";
/** Tooltip für den Button „Standard-Prompt erstellen" im Prompt-Editor-Tab. */
public static final String PROMPT_STANDARD_ANLEGEN =
"Standard-Prompt-Datei am konfigurierten Pfad anlegen.";
/** Nicht instanziierbar reine Konstantenklasse. */
private GuiTooltipTexts() {
throw new UnsupportedOperationException("Nicht instanziierbar");
}
}
@@ -7,7 +7,12 @@ import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuBar;
import javafx.scene.control.MenuItem;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
@@ -25,6 +30,10 @@ import javafx.stage.WindowEvent;
*
* <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert.
* Über das Tray-Kontextmenü kann das Fenster wieder geöffnet oder die Anwendung beendet werden.
*
* <p>Nach dem Anzeigen des Hauptfensters startet eine zentrale {@link GuiStatusRefreshTimeline}
* (1 Hz), die den aktuellen Scheduler-Status liest und alle betroffenen Tabs aktualisiert.
* Die Timeline wird beim Beenden der Anwendung gestoppt.
*/
public class PdfUmbenennerGuiApplication extends Application {
@@ -33,6 +42,9 @@ public class PdfUmbenennerGuiApplication extends Application {
private static final double DEFAULT_HEIGHT = 800;
private SystemTrayManager trayManager;
private GuiConfigurationEditorWorkspace workspace;
private GuiStartupContext guiStartupContext;
private GuiStatusRefreshTimeline refreshTimeline;
/**
* Creates a new instance of the JavaFX application.
@@ -48,6 +60,8 @@ public class PdfUmbenennerGuiApplication extends Application {
* Wires the workspace title-update listener to the stage title so any dirty-state change
* causes an immediate window-title refresh. Installs the close-request handler that
* guards unsaved changes and minimizes the window to the system tray instead of closing.
* <p>
* Startet nach dem Anzeigen des Fensters die zentrale Status-Refresh-Timeline.
*
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
*/
@@ -63,13 +77,26 @@ public class PdfUmbenennerGuiApplication extends Application {
new Image(getClass().getResourceAsStream("/icons/Icon128.png"))
);
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
guiStartupContext = GuiStartupContextHolder.currentOrBlank();
workspace = new GuiConfigurationEditorWorkspace(guiStartupContext);
// Wire the title-update listener so the stage title stays in sync with the dirty state.
workspace.titleUpdateListener = primaryStage::setTitle;
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
// Statuszeile anlegen und mit dem Workspace verdrahten
GuiStatusBar statusBar = new GuiStatusBar(guiStartupContext.applicationVersion());
workspace.statusBarStateListener = statusBar::applyEditorState;
// Menüleiste mit Datenbank-Menü (Neue Datenbank anlegen")
MenuBar menuBar = buildMenuBar(workspace);
// Statuszeile unterhalb des Workspace-Inhalts einbetten
BorderPane outerLayout = new BorderPane();
outerLayout.setTop(menuBar);
outerLayout.setCenter(workspace.root());
outerLayout.setBottom(statusBar.root());
Scene scene = new Scene(outerLayout, DEFAULT_WIDTH, DEFAULT_HEIGHT);
primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
primaryStage.setScene(scene);
@@ -83,28 +110,114 @@ public class PdfUmbenennerGuiApplication extends Application {
installTrayCloseHandler(primaryStage, workspace);
}
// Scheduler-Close-Guard als äußerste Schicht: verhindert Beenden während Scheduler aktiv
installSchedulerCloseGuard(primaryStage);
primaryStage.setMaximized(true);
primaryStage.show();
// Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden.
workspace.autoLoadLastConfiguration();
// Zentrale Status-Refresh-Timeline starten (1 Hz)
refreshTimeline = new GuiStatusRefreshTimeline(
guiStartupContext.schedulerControlUseCase(),
this::refreshAllTabStates);
refreshTimeline.start();
LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
}
/**
* Called by the JavaFX runtime when the application is stopping.
* <p>
* Entfernt das System-Tray-Icon und loggt das Beenden.
* Stoppt die Status-Refresh-Timeline, entfernt das System-Tray-Icon und loggt das Beenden.
*/
@Override
public void stop() {
LOG.info("GUI: JavaFX-Anwendung wird beendet.");
if (refreshTimeline != null) {
refreshTimeline.stop();
}
if (trayManager != null) {
trayManager.remove();
}
}
/**
* Liest den aktuellen Scheduler-Status und aktualisiert alle betroffenen Tabs.
* <p>
* Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem JavaFX
* Application Thread aufgerufen. Wenn kein {@link SchedulerControlUseCase} vorhanden
* ist, wird der Aufruf ohne Fehler übersprungen.
*/
private void refreshAllTabStates() {
// Den Use Case nicht aus dem unveränderlichen GuiStartupContext lesen, sondern
// den zur Laufzeit (z. B. durch Auto-Load) verdrahteten Use Case verwenden.
// Andernfalls bliebe der Stop-Button dauerhaft deaktiviert, weil updateStatus
// nie aufgerufen würde.
workspace.refreshSchedulerStatus();
}
/**
* Baut die Menüleiste für das Hauptfenster auf.
* <p>
* Aktuell enthält sie genau einen Eintrag: das Menü Datenbank" mit der Aktion
* Neue Datenbank anlegen". Diese delegiert an
* {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}.
* <p>
* Der Menüpunkt ist deaktiviert, solange ein Verarbeitungslauf aktiv ist oder
* bereits eine DB-Anlage läuft. Die Reaktivierung erfolgt automatisch, sobald
* der Workspace die DB-Busy-Sperre wieder aufhebt.
*
* @param workspace der Workspace, an den die Aktionen delegieren; nie {@code null}
* @return die fertig konfigurierte Menüleiste
*/
private MenuBar buildMenuBar(GuiConfigurationEditorWorkspace workspace) {
Menu databaseMenu = new Menu("Datenbank");
MenuItem createNewItem = new MenuItem("Neue Datenbank anlegen…");
createNewItem.setOnAction(event -> workspace.requestCreateNewDatabase());
// Sperre während eines aktiven Verarbeitungslaufs oder einer laufenden DB-Anlage
createNewItem.disableProperty().bind(workspace.batchRunRunningProperty()
.or(workspace.dbBusyForDatabaseCreationProperty()));
databaseMenu.getItems().add(createNewItem);
return new MenuBar(databaseMenu);
}
/**
* Legt den Scheduler-Close-Guard als äußerste Schicht des Close-Request-Handlers an.
* <p>
* Ist kein {@link de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase}
* vorhanden, bleibt der bestehende Handler unverändert. Ist der Scheduler aktiv
* (Zustand != {@code STOPPED}), wird das Schließen verhindert und ein
* Informationsdialog angezeigt. Ist der Scheduler gestoppt, wird der bisherige
* Handler (SystemTray + Workspace-Dirty-Guard) aufgerufen.
*
* @param stage das primäre Fenster; darf nicht {@code null} sein
*/
private void installSchedulerCloseGuard(Stage stage) {
EventHandler<WindowEvent> existingHandler = stage.getOnCloseRequest();
stage.setOnCloseRequest(event -> {
// Use Case dynamisch über den Workspace lesen, weil der Scheduler erst
// nach erfolgreichem Datei-Öffnen (z. B. Auto-Load) verdrahtet wird und
// damit nicht zwingend im unveränderlichen GuiStartupContext steht.
if (workspace.isSchedulerActive()) {
event.consume();
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle("Anwendung kann nicht beendet werden");
alert.setHeaderText(null);
alert.setContentText(
"Ein Lauf ist aktiv oder der Scheduler läuft.\n"
+ "Bitte beende den Scheduler bzw. warte auf das Ende des Laufs.");
alert.showAndWait();
return;
}
if (existingHandler != null) {
existingHandler.handle(event);
}
});
}
/**
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
* System-Tray minimiert statt es zu schließen.
@@ -2,7 +2,7 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
/**
* Übersetzt strukturierte Fehlermeldungen aus der Anwendungsschicht in
* benutzerfreundliche deutsche Texte für den Detailbereich des Verarbeitungslauf-Tabs.
* benutzerfreundliche deutsche Texte für die Darstellungsschicht der GUI.
* <p>
* Die Klasse wertet die englischsprachige Fehlermeldung aus dem Verarbeitungsversuch
* musterbasiert aus und liefert eine für den Endbenutzer lesbare Beschreibung des
@@ -12,8 +12,10 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
* Die Mustererkennung erfolgt ohne Berücksichtigung der Groß-/Kleinschreibung
* und prüft die definierten Schlüsselbegriffe in festgelegter Reihenfolge,
* damit spezifischere Muster vor allgemeineren greifen.
* <p>
* Die Klasse wird sowohl im Verarbeitungslauf-Tab als auch im Verlauf-Tab verwendet.
*/
final class AiFailureMessageTranslator {
public final class AiFailureMessageTranslator {
private AiFailureMessageTranslator() {
}
@@ -28,7 +30,7 @@ final class AiFailureMessageTranslator {
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
* @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol
*/
static String translate(String technicalMessage) {
public static String translate(String technicalMessage) {
if (technicalMessage == null || technicalMessage.isBlank()) {
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
}
@@ -0,0 +1,338 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
/**
* Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs
* die aggregierten Ergebnisse anzeigt.
*
* <p>Das Banner erscheint nach Laufabschluss unterhalb des Fortschrittsbalkens und
* oberhalb der Ergebnistabelle. Es zeigt nur Kategorien, deren Zähler größer als null
* ist. Folgende Status werden nicht gezählt und tauchen nie im Banner auf:
* {@code READY_FOR_AI}, {@code PROPOSAL_READY} und {@code PROCESSING} sind im
* Enum {@link DocumentCompletionStatus} nicht enthalten alle enthaltenen Werte
* werden gezählt, außer Einträgen mit {@code resetPending=true}, da diese keinen
* abgeschlossenen Zustand darstellen.
*
* <p>Farbe ist niemals das einzige Unterscheidungsmerkmal: Jedes Segment enthält
* ein Icon und einen Text.
*
* <p>Die öffentlichen Methoden {@link #clear()} und {@link #update(Map)} sind
* thread-agnostisch definiert, aber müssen auf dem JavaFX Application Thread aufgerufen
* werden (oder das Banner muss via {@code Platform.runLater} aktualisiert werden).
* Die Aggregations-Hilfsmethode {@link #aggregateCounts(Iterable)} ist vollständig
* unabhängig von JavaFX und kann auf jedem Thread aufgerufen werden.
*/
public final class BatchRunSummaryBanner {
/** Trennzeichen zwischen den Kategoriesegmenten. */
private static final String SEGMENT_SEPARATOR = " · ";
/** Abstand zwischen den Label-Segmenten in Pixeln. */
private static final int SPACING = 0;
/** Innerer Abstand des Containers in Pixeln (oben/unten). */
private static final double PADDING_V = 4.0;
/** Standardfarbe für den Summentext. */
private static final String STYLE_DEFAULT = "-fx-font-size: 12;";
/**
* Alle {@link DocumentCompletionStatus}-Werte, die im Banner angezeigt werden,
* in der verbindlichen Anzeigereihenfolge gemäß Spezifikation.
*/
private static final List<DocumentCompletionStatus> DISPLAYED_ORDER = List.of(
DocumentCompletionStatus.SUCCESS,
DocumentCompletionStatus.FAILED_RETRYABLE,
DocumentCompletionStatus.FAILED_PERMANENT,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE
);
/** Wurzel-Container des Banners wird in das Tab-Layout eingebettet. */
private final HBox container;
/**
* Vertikale Halterung fuer mehrzeilige Banner-Inhalte.
*
* <p>Enthaelt die Status-Zeile (bestehend), die Token-Zeile, die
* Kosten-Zeile und optional eine Cache-only-Zeile. Die Token-/Kosten-
* /Cache-Zeilen werden in V3.3 mit einem leeren {@link BatchRunTokenSummary}
* vorbelegt; die echten Aggregat-Werte werden durch das nachfolgende
* Arbeitspaket geliefert.
*/
private final VBox lineContainer;
/** Label, das den kompletten Bannertext als Inline-Segmente trägt. */
private final Label contentLabel;
/** Label fuer die Tokenzeile (Input/Output). */
private final Label tokenLabel;
/** Label fuer die Kosten-Zeile. */
private final Label costLabel;
/** Label fuer die optionale Cache-only-Hinweiszeile. */
private final Label cacheOnlyLabel;
/**
* Erstellt ein neues, initial unsichtbares Summary-Banner.
*/
public BatchRunSummaryBanner() {
contentLabel = new Label();
contentLabel.setStyle(STYLE_DEFAULT);
contentLabel.setWrapText(false);
tokenLabel = new Label();
tokenLabel.setStyle(STYLE_DEFAULT);
costLabel = new Label();
costLabel.setStyle(STYLE_DEFAULT);
cacheOnlyLabel = new Label();
cacheOnlyLabel.setStyle(STYLE_DEFAULT);
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
lineContainer = new VBox(2, contentLabel, tokenLabel, costLabel, cacheOnlyLabel);
lineContainer.setAlignment(Pos.CENTER_LEFT);
container = new HBox(SPACING, lineContainer);
container.setAlignment(Pos.CENTER_LEFT);
container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;");
// Initial unsichtbar, nimmt keinen Platz ein
container.setVisible(false);
container.setManaged(false);
}
// -------------------------------------------------------------------------
// Öffentliche API
// -------------------------------------------------------------------------
/**
* Versteckt das Banner und leert seinen Inhalt.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clear() {
contentLabel.setText("");
tokenLabel.setText("");
costLabel.setText("");
cacheOnlyLabel.setText("");
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
container.setVisible(false);
container.setManaged(false);
}
/**
* Aktualisiert das Banner mit den aggregierten Zählern und macht es sichtbar.
*
* <p>Zeigt nur Kategorien mit Anzahl &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) {
update(counts, BatchRunTokenSummary.empty());
}
/**
* Aktualisiert das Banner mit Status-Zaehlern und Token-/Kosten-Aggregaten.
*
* <p>Zeigt die Status-Zeile (wenn nicht leer) sowie die Token- und Kosten-
* Zeilen. Die Cache-only-Zeile erscheint nur, wenn {@link
* BatchRunTokenSummary#cacheOnlyAttemptCount()} groesser als 0 ist.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param counts Zaehler je Status; nicht {@code null}
* @param tokenSummary Aggregat-Werte fuer Tokens und Kosten; nicht {@code null}
*/
public void update(Map<DocumentCompletionStatus, Integer> counts,
BatchRunTokenSummary tokenSummary) {
Objects.requireNonNull(counts, "counts darf nicht null sein");
Objects.requireNonNull(tokenSummary, "tokenSummary darf nicht null sein");
String text = buildBannerText(counts);
contentLabel.setText(text);
tokenLabel.setText(buildTokenLine(tokenSummary));
costLabel.setText(buildCostLine(tokenSummary));
if (tokenSummary.cacheOnlyAttemptCount() > 0) {
cacheOnlyLabel.setText(buildCacheOnlyLine(tokenSummary.cacheOnlyAttemptCount()));
cacheOnlyLabel.setVisible(true);
cacheOnlyLabel.setManaged(true);
} else {
cacheOnlyLabel.setText("");
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
}
if (text.isEmpty() && !tokenSummary.hasAnyData()) {
clear();
return;
}
container.setVisible(true);
container.setManaged(true);
}
/**
* Token-/Kosten-Aggregat fuer einen Banner-Eintrag.
*
* <p>Ein {@link #empty()}-Default reicht, solange das Read-Model fuer
* Aggregate noch nicht implementiert ist. AP-B liefert spaeter die echten
* Werte ueber den {@code TokenStatisticsReadModelPort}.
*
* @param totalInputTokens Summe Input-Tokens; ggf. {@code 0}
* @param totalOutputTokens Summe Output-Tokens; ggf. {@code 0}
* @param totalCostUsd Summe der Kosten in USD; ggf. {@code BigDecimal.ZERO}
* @param hasMissingPriceSnapshot {@code true}, wenn mind. ein Versuch ohne Preis-Snapshot vorlag
* @param hasCacheTokensIgnored {@code true}, wenn Cache-Tokens vorkamen
* @param cacheOnlyAttemptCount Anzahl Cache-only-Versuche im Lauf
*/
public record BatchRunTokenSummary(
long totalInputTokens,
long totalOutputTokens,
BigDecimal totalCostUsd,
boolean hasMissingPriceSnapshot,
boolean hasCacheTokensIgnored,
long cacheOnlyAttemptCount) {
/**
* Liefert ein leeres Aggregat (alle Zaehler null, Kosten 0).
*
* @return leeres Aggregat
*/
public static BatchRunTokenSummary empty() {
return new BatchRunTokenSummary(0L, 0L, BigDecimal.ZERO, false, false, 0L);
}
/**
* Pruefung, ob ueberhaupt Daten zum Anzeigen vorliegen.
*
* @return {@code true} bei Werten ungleich 0
*/
public boolean hasAnyData() {
return totalInputTokens > 0 || totalOutputTokens > 0
|| (totalCostUsd != null && totalCostUsd.signum() != 0)
|| cacheOnlyAttemptCount > 0;
}
}
private static String buildTokenLine(BatchRunTokenSummary s) {
return String.format(Locale.GERMAN, "Tokens: Input %,d Output %,d",
s.totalInputTokens(), s.totalOutputTokens());
}
private static String buildCostLine(BatchRunTokenSummary s) {
BigDecimal cost = s.totalCostUsd() != null ? s.totalCostUsd() : BigDecimal.ZERO;
BigDecimal rounded = cost.setScale(4, RoundingMode.HALF_UP);
StringBuilder sb = new StringBuilder("Kosten: $").append(rounded.toPlainString());
if (s.hasCacheTokensIgnored()) {
sb.append(" (ohne Cache-Anteil)");
}
if (s.hasMissingPriceSnapshot()) {
sb.append(" (unvollstaendig)");
}
return sb.toString();
}
private static String buildCacheOnlyLine(long count) {
return " " + count + " Cache-only Versuche (in Kosten nicht enthalten)";
}
/**
* Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout.
*
* @return der Container-Knoten; nie null
*/
public HBox getNode() {
return container;
}
// -------------------------------------------------------------------------
// Aggregations-Hilfe (thread-agnostisch, testbar ohne JavaFX)
// -------------------------------------------------------------------------
/**
* Zählt die Anzahl jedes {@link DocumentCompletionStatus} in der übergebenen
* Iterable. Einträge mit {@code resetPending=true} werden ignoriert, da sie
* keinen abgeschlossenen Verarbeitungszustand darstellen.
*
* <p>Diese Methode ist vollständig unabhängig von JavaFX und kann auf jedem
* Thread aufgerufen werden.
*
* @param rows die Ergebniszeilen des Laufs; darf nicht null sein;
* null-Elemente werden übersprungen
* @return eine Map mit der Anzahl je Status; enthält alle anzuzeigenden
* Status (fehlende haben Wert 0); nie null
*/
public static Map<DocumentCompletionStatus, Integer> aggregateCounts(
Iterable<? extends GuiBatchRunResultRow> rows) {
Objects.requireNonNull(rows, "rows darf nicht null sein");
Map<DocumentCompletionStatus, Integer> counts = new EnumMap<>(DocumentCompletionStatus.class);
// Alle anzuzeigenden Status mit 0 vorbelegen
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
counts.put(status, 0);
}
for (GuiBatchRunResultRow row : rows) {
if (row == null) {
continue;
}
// Reset-Pending-Zeilen zählen nicht sie haben noch keinen abgeschlossenen Status
if (row.resetPending()) {
continue;
}
DocumentCompletionStatus status = row.status();
// Nur anzuzeigende Status zählen (entspricht dem Ausschluss von
// Übergangszuständen wie READY_FOR_AI, PROPOSAL_READY, PROCESSING)
if (counts.containsKey(status)) {
counts.merge(status, 1, Integer::sum);
}
}
return counts;
}
// -------------------------------------------------------------------------
// Interne Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Erzeugt den angezeigten Bannertext aus den Zählern.
* Liefert einen leeren String wenn alle Zähler null sind.
*
* @param counts die Zähler je Status; darf nicht null sein
* @return der fertige Bannertext oder ein leerer String
*/
static String buildBannerText(Map<DocumentCompletionStatus, Integer> counts) {
List<String> segments = new ArrayList<>();
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
int count = counts.getOrDefault(status, 0);
if (count > 0) {
String icon = ProcessingStatusPresentation.iconFor(status);
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
segments.add(icon + " " + count + " " + category);
}
}
return String.join(SEGMENT_SEPARATOR, segments);
}
}
@@ -6,17 +6,20 @@ import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
/**
* Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
@@ -73,6 +76,9 @@ public final class FileNameEditorPane {
sectionTitle.setStyle("-fx-font-weight: bold;");
textField.setId("filename-editor-text-field");
Tooltip textFieldTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_TEXTFELD);
textFieldTooltip.setShowDelay(Duration.millis(300));
textField.setTooltip(textFieldTooltip);
HBox.setHgrow(textField, Priority.ALWAYS);
HBox inputRow = new HBox(4, textField);
@@ -86,9 +92,15 @@ public final class FileNameEditorPane {
saveButton.setId("filename-editor-save-button");
saveButton.setOnAction(e -> fireSaveRequest());
Tooltip saveTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
saveTooltip.setShowDelay(Duration.millis(300));
saveButton.setTooltip(saveTooltip);
resetButton.setId("filename-editor-reset-button");
resetButton.setOnAction(e -> resetToAiProposal());
Tooltip resetTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
resetTooltip.setShowDelay(Duration.millis(300));
resetButton.setTooltip(resetTooltip);
HBox buttonRow = new HBox(8, saveButton, resetButton);
buttonRow.setAlignment(Pos.CENTER_LEFT);
@@ -21,9 +21,12 @@ import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import javafx.application.Platform;
import javafx.scene.control.Alert;
/**
* Coordinates a single batch run (regular or targeted mini-run) triggered from the
@@ -60,6 +63,9 @@ import javafx.application.Platform;
* </ol>
*/
public final class GuiBatchRunCoordinator {
private static final String CONFIG_FILE_NOT_NULL = "configFilePath must not be null";
private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
private static final String WORKER_THREAD_NAME = "gui-batch-run";
@@ -115,6 +121,7 @@ public final class GuiBatchRunCoordinator {
private final Consumer<Runnable> fxDispatcher;
private final Listener listener;
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
private final Optional<ConfigurationFileLockPort> configurationFileLockPort;
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
@@ -176,6 +183,33 @@ public final class GuiBatchRunCoordinator {
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort);
}
/**
* Creates the coordinator with all ports and the configuration file lock port, using
* the default worker-thread factory and JavaFX Application Thread dispatcher.
* <p>
* This constructor is intended for production wiring in {@code GuiBatchRunTab} where
* the lock port is supplied by Bootstrap.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param listener GUI listener invoked on the FX thread; must not be null
* @param historicalDocumentContextPort port for resolving historical context; must not be null
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
* acquired before each run; {@code null} is treated as empty
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
this(launcher, miniRunLauncher, resetPort,
defaultThreadFactory(), defaultFxDispatcher(), listener,
historicalDocumentContextPort, configurationFileLockPort);
}
/**
* Creates the coordinator with custom hooks for the worker-thread factory and the
* UI-thread dispatcher.
@@ -205,8 +239,8 @@ public final class GuiBatchRunCoordinator {
}
/**
* Creates the coordinator with all ports, custom thread factory, FX dispatcher and
* historical file name port.
* Creates the coordinator with all ports, custom thread factory, FX dispatcher,
* historical file name port, and an optional configuration file lock port.
* <p>
* This is the canonical constructor. All other constructors delegate here.
*
@@ -221,6 +255,47 @@ public final class GuiBatchRunCoordinator {
* @param listener GUI listener; must not be null
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
* skipped documents; must not be null
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
* acquired before each run and released in a finally block;
* {@code null} is treated as empty
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
this.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
this.listener = Objects.requireNonNull(listener, "listener must not be null");
this.historicalDocumentContextPort = Objects.requireNonNull(
historicalDocumentContextPort, "historicalDocumentContextPort must not be null");
this.configurationFileLockPort =
Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
}
/**
* Backward-compatible constructor that omits the configuration file lock port.
* <p>
* Preserves existing callers that were written before the lock port was added.
* Delegates to the canonical constructor with {@code configurationFileLockPort} empty.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param threadFactory factory returning a ready-to-start worker thread; must not
* be null
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
* Thread; must not be null
* @param listener GUI listener; must not be null
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
* skipped documents; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
@@ -229,14 +304,8 @@ public final class GuiBatchRunCoordinator {
Consumer<Runnable> fxDispatcher,
Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
this.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
this.listener = Objects.requireNonNull(listener, "listener must not be null");
this.historicalDocumentContextPort = Objects.requireNonNull(
historicalDocumentContextPort, "historicalDocumentContextPort must not be null");
this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
historicalDocumentContextPort, Optional.empty());
}
/**
@@ -287,7 +356,7 @@ public final class GuiBatchRunCoordinator {
* @throws NullPointerException if {@code configFilePath} is {@code null}
*/
public boolean start(Path configFilePath) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
if (isRunning()) {
return false;
}
@@ -313,7 +382,7 @@ public final class GuiBatchRunCoordinator {
*/
public boolean startMiniRun(Path configFilePath,
Set<DocumentFingerprint> fingerprintFilter) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
if (isRunning()) {
return false;
@@ -345,7 +414,7 @@ public final class GuiBatchRunCoordinator {
*/
public boolean startReprocessing(Path configFilePath,
Set<DocumentFingerprint> fingerprintFilter) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
if (isRunning()) {
return false;
@@ -386,7 +455,7 @@ public final class GuiBatchRunCoordinator {
* @throws NullPointerException if any argument is {@code null}
*/
public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
if (isRunning()) {
return false;
@@ -437,6 +506,21 @@ public final class GuiBatchRunCoordinator {
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
configFilePath);
observerSummary.set(null);
if (configurationFileLockPort.isPresent()) {
try {
configurationFileLockPort.get().acquireLock();
} catch (ConfigurationFileLockException e) {
LOG.warn("GUI-Verarbeitungslauf: Konfigurationsdatei gesperrt Lauf abgebrochen: {}",
e.getMessage());
fxDispatcher.accept(() -> showLockErrorAlert());
finishRun(GuiBatchRunLaunchOutcome.rejected(
"Konfigurationsdatei gesperrt Lauf wurde abgebrochen."));
return;
}
}
try {
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
@@ -454,12 +538,30 @@ public final class GuiBatchRunCoordinator {
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
finishRun(outcome);
} finally {
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
}
}
private void executeMiniRun(Path configFilePath, Set<DocumentFingerprint> fingerprintFilter) {
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
observerSummary.set(null);
if (configurationFileLockPort.isPresent()) {
try {
configurationFileLockPort.get().acquireLock();
} catch (ConfigurationFileLockException e) {
LOG.warn("GUI-Mini-Verarbeitungslauf: Konfigurationsdatei gesperrt Lauf abgebrochen: {}",
e.getMessage());
fxDispatcher.accept(() -> showLockErrorAlert());
finishRun(GuiBatchRunLaunchOutcome.rejected(
"Konfigurationsdatei gesperrt Mini-Lauf wurde abgebrochen."));
return;
}
}
try {
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
@@ -477,6 +579,9 @@ public final class GuiBatchRunCoordinator {
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
finishRun(outcome);
} finally {
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
}
}
private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
@@ -611,6 +716,19 @@ public final class GuiBatchRunCoordinator {
historicalContext);
}
private static void showLockErrorAlert() {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Verarbeitungslauf nicht möglich");
alert.setHeaderText("Konfigurationsdatei gesperrt");
alert.setContentText(
"Der Verarbeitungslauf konnte nicht gestartet werden, da die "
+ "Konfigurationsdatei nicht gesperrt werden konnte.\n\n"
+ "Mögliche Ursache: Der automatische Scheduler ist aktiv oder "
+ "ein anderer Prozess hält die Datei belegt.\n\n"
+ "Bitte stoppen Sie den Scheduler und versuchen Sie es erneut.");
alert.showAndWait();
}
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
return (configPath, fingerprint) -> Optional.empty();
}
@@ -33,7 +33,7 @@ public record GuiBatchRunLaunchOutcome(
* Compact constructor normalising the failure message holder.
*/
public GuiBatchRunLaunchOutcome {
failureMessage = failureMessage == null ? Optional.empty() : failureMessage;
failureMessage = Objects.requireNonNullElse(failureMessage, Optional.empty());
}
/**
@@ -88,16 +88,16 @@ public record GuiBatchRunResultRow(
}
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(status, "status must not be null");
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
correctedFileName = correctedFileName == null ? Optional.empty() : correctedFileName;
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
aiFailureMessage = aiFailureMessage == null ? Optional.empty() : aiFailureMessage;
finalFileName = Objects.requireNonNullElse(finalFileName, Optional.empty());
correctedFileName = Objects.requireNonNullElse(correctedFileName, Optional.empty());
resolvedDate = Objects.requireNonNullElse(resolvedDate, Optional.empty());
aiReasoning = Objects.requireNonNullElse(aiReasoning, Optional.empty());
aiFailureMessage = Objects.requireNonNullElse(aiFailureMessage, Optional.empty());
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
if (processingDuration.isNegative()) {
throw new IllegalArgumentException("processingDuration must not be negative");
}
historicalContext = historicalContext == null ? Optional.empty() : historicalContext;
historicalContext = Objects.requireNonNullElse(historicalContext, Optional.empty());
}
/**
@@ -197,6 +197,8 @@ public record GuiBatchRunResultRow(
* <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
*/
@@ -204,13 +206,7 @@ public record GuiBatchRunResultRow(
if (resetPending) {
return RESET_PENDING_ICON;
}
return switch (status) {
case SUCCESS -> ""; // CHECK MARK
case FAILED_RETRYABLE -> ""; // CLOCKWISE OPEN CIRCLE ARROW
case FAILED_PERMANENT -> "×"; // × MULTIPLICATION SIGN
case SKIPPED_ALREADY_PROCESSED -> ""; // IDENTICAL TO
case SKIPPED_FINAL_FAILURE -> ""; // CIRCLED DIVISION SLASH
};
return ProcessingStatusPresentation.iconFor(status);
}
/**
@@ -218,20 +214,36 @@ public record GuiBatchRunResultRow(
* <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. "#2e7d32")
* @return die entsprechende CSS-Hex-Farbe (z. B. {@code "#2e7d32"})
*/
public String statusColor() {
if (resetPending) {
return "#757575"; // Grau für Reset-pending
}
return switch (status) {
case SUCCESS -> "#2e7d32"; // Grün
case FAILED_RETRYABLE -> "#d98200"; // Orange
case FAILED_PERMANENT -> "#c62828"; // Rot
case SKIPPED_ALREADY_PROCESSED -> "#1565c0"; // Blau-Grau
case SKIPPED_FINAL_FAILURE -> "#757575"; // Grau
};
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);
}
/**
@@ -249,7 +261,7 @@ public record GuiBatchRunResultRow(
return switch (status) {
case SUCCESS -> "Erfolgreich";
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
case FAILED_PERMANENT -> "Fehlgeschlagen (permanent)";
case FAILED_PERMANENT -> "Fehlgeschlagen (dauerhaft)";
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen (bereits verarbeitet)";
case SKIPPED_FINAL_FAILURE -> "Übersprungen (endgültig fehlgeschlagen)";
};
@@ -41,8 +41,11 @@ import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSourceFile
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
@@ -67,6 +70,7 @@ import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
@@ -107,6 +111,11 @@ import javafx.scene.layout.VBox;
* dafür, Hintergrundereignisse vor dem Callback auf den FX-Thread zu übertragen.
*/
public final class GuiBatchRunTab {
private static final String COPY_FAILED_LOG = "Manuelle Dateikopie fehlgeschlagen: {}";
private static final String RENAME_FAILED_LOG = "Manuelle Dateiumbenennung fehlgeschlagen: {}";
private static final String DIRTY_STATE_MSG = "Dateiname-Editor: Ungespeicherte Änderungen";
private static final Logger LOG = LogManager.getLogger(GuiBatchRunTab.class);
@@ -192,12 +201,18 @@ public final class GuiBatchRunTab {
private final Button resetStatusButton = new Button("Status zurücksetzen");
private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false);
/** {@code true} while the automatic scheduler is in any non-{@code STOPPED} state. */
private boolean schedulerActive = false;
/** Dateiname-Editor-Komponente im Detailbereich. */
private final FileNameEditorPane fileNameEditor = new FileNameEditorPane();
/** PDF-Vorschau-Komponente im Detailbereich. */
private final PdfPreviewPane pdfPreview = new PdfPreviewPane();
/** Summary-Banner unterhalb des Fortschrittsbalkens sichtbar nach Laufabschluss. */
private final BatchRunSummaryBanner summaryBanner = new BatchRunSummaryBanner();
private final Supplier<Path> configPathSupplier;
private final BooleanSupplier savedConfigurationReadyCheck;
private final Runnable onRunStateChanged;
@@ -226,7 +241,8 @@ public final class GuiBatchRunTab {
/**
* Erstellt den Verarbeitungslauf-Tab mit allen Verarbeitungs-, Mini-Lauf- und
* Rücksetz-Fähigkeiten sowie dem Dateiname-Editor und der PDF-Vorschau.
* Rücksetz-Fähigkeiten sowie dem Dateiname-Editor, der PDF-Vorschau und einem
* optionalen OS-Lock auf die Konfigurationsdatei.
*
* @param launcherSupplier Supplier für den aktiven Batch-Lauf-Launcher;
* darf nicht null sein
@@ -251,6 +267,9 @@ public final class GuiBatchRunTab {
* darf leeres Optional zurückliefern
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner als
* Pfad-String; darf leeres Optional zurückliefern
* @param configurationFileLockPort optionaler OS-Lock auf die Konfigurationsdatei;
* wird vor jedem Lauf erworben und danach freigegeben;
* {@code null} wird als leer behandelt
*/
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
@@ -262,7 +281,8 @@ public final class GuiBatchRunTab {
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
Supplier<Optional<Path>> sourceFolderSupplier,
Supplier<Optional<String>> targetFolderSupplier) {
Supplier<Optional<String>> targetFolderSupplier,
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
Objects.requireNonNull(miniRunLauncherSupplier, "miniRunLauncherSupplier must not be null");
Objects.requireNonNull(resetPortSupplier, "resetPortSupplier must not be null");
@@ -281,6 +301,8 @@ public final class GuiBatchRunTab {
this.targetFolderSupplier = Objects.requireNonNull(
targetFolderSupplier, "targetFolderSupplier must not be null");
Optional<ConfigurationFileLockPort> effectiveLockPort =
Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
this.coordinator = new GuiBatchRunCoordinator(
(configPath, observer, token) ->
launcherSupplier.get().launch(configPath, observer, token),
@@ -289,7 +311,8 @@ public final class GuiBatchRunTab {
(configPath, fingerprints) ->
resetPortSupplier.get().reset(configPath, fingerprints),
new CoordinatorListener(),
historicalDocumentContextPortSupplier.get());
historicalDocumentContextPortSupplier.get(),
effectiveLockPort);
this.tab.setClosable(false);
this.tab.setContent(buildContent());
@@ -308,6 +331,51 @@ public final class GuiBatchRunTab {
updateButtonStates();
}
/**
* Rückwärtskompatible Variante ohne OS-Lock auf die Konfigurationsdatei.
* <p>
* Alle bestehenden Aufrufer, die vor der Lock-Port-Erweiterung erstellt wurden,
* nutzen diesen Konstruktor. Er delegiert an den kanonischen Konstruktor mit
* {@code configurationFileLockPort = Optional.empty()}.
*
* @param launcherSupplier Supplier für den aktiven Batch-Lauf-Launcher;
* darf nicht null sein
* @param miniRunLauncherSupplier Supplier für den Mini-Lauf-Launcher;
* darf nicht null sein
* @param resetPortSupplier Supplier für den Rücksetz-Port;
* darf nicht null sein
* @param configPathSupplier Supplier für den letzten gespeicherten
* Konfigurationspfad; darf null zurückliefern
* @param savedConfigurationReadyCheck Prüfung vor jedem Startversuch; darf nicht
* null sein
* @param onRunStateChanged Callback wenn das Lauf-Flag kippt; darf nicht
* null sein
* @param manualFileRenamePortSupplier Supplier für den manuellen Umbenennungs-Port;
* darf nicht null sein
* @param manualFileCopyPortSupplier Supplier für den manuellen Kopier-Port;
* darf nicht null sein
* @param historicalDocumentContextPortSupplier Supplier für den historischen Kontext-Port;
* darf nicht null sein
* @param sourceFolderSupplier Supplier für den konfigurierten Quellordner
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner
*/
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
Supplier<GuiResetDocumentStatusPort> resetPortSupplier,
Supplier<Path> configPathSupplier,
BooleanSupplier savedConfigurationReadyCheck,
Runnable onRunStateChanged,
Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
Supplier<Optional<Path>> sourceFolderSupplier,
Supplier<Optional<String>> targetFolderSupplier) {
this(launcherSupplier, miniRunLauncherSupplier, resetPortSupplier, configPathSupplier,
savedConfigurationReadyCheck, onRunStateChanged, manualFileRenamePortSupplier,
manualFileCopyPortSupplier, historicalDocumentContextPortSupplier,
sourceFolderSupplier, targetFolderSupplier, Optional.empty());
}
/**
* Rückwärtskompatible Variante für Aufrufer ohne Mini-Lauf- oder Rücksetz-Fähigkeiten.
*
@@ -433,6 +501,25 @@ public final class GuiBatchRunTab {
return askDiscardFilenameChanges();
}
/**
* Aktualisiert den Tab-Zustand anhand des aktuellen Scheduler-Status.
* <p>
* Deaktiviert den Starten-Button und setzt einen erklärenden Tooltip, solange
* der Scheduler aktiv ist. Wenn der Scheduler gestoppt ist, wird der normale
* Button-Zustand wiederhergestellt (Starten erlaubt sofern kein Lauf läuft).
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
*/
public void updateSchedulerState(SchedulerStatus status) {
schedulerActive = status.state().isActive();
startButton.setDisable(runningProperty.get() || schedulerActive);
startButton.setTooltip(new Tooltip(schedulerActive
? "Manuelle Läufe sind während aktivem Scheduler nicht möglich."
: GuiTooltipTexts.BATCHRUN_STARTEN));
}
// -------------------------------------------------------------------------
// Paket-private Accessor für Tests
// -------------------------------------------------------------------------
@@ -500,8 +587,14 @@ public final class GuiBatchRunTab {
HBox.setHgrow(progressBar, Priority.ALWAYS);
counterLabel.setId("batch-run-counter");
HBox header = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
header.setAlignment(Pos.CENTER_LEFT);
HBox progressRow = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
progressRow.setAlignment(Pos.CENTER_LEFT);
// Summary-Banner unterhalb des Fortschrittsbalkens, oberhalb der Tabelle
HBox bannerNode = summaryBanner.getNode();
bannerNode.setId("batch-run-summary-banner");
VBox header = new VBox(0, progressRow, bannerNode);
header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0));
return header;
}
@@ -517,19 +610,22 @@ public final class GuiBatchRunTab {
// Selektions-Aktions-Buttons unterhalb der Tabelle (linke Spalte)
reprocessButton.setId("batch-run-reprocess");
reprocessButton.setOnAction(event -> handleReprocessSelected());
reprocessButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ERNEUT_VERARBEITEN));
resetStatusButton.setId("batch-run-reset-status");
resetStatusButton.setOnAction(event -> handleResetSelected());
resetStatusButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STATUS_ZURUECKSETZEN));
HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton);
selectionButtonBar.setAlignment(Pos.CENTER_LEFT);
selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, SECONDARY_SPACING / 2, 0));
selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2.0, 0, SECONDARY_SPACING / 2.0, 0));
// Meldungsbereich unterhalb der Selektions-Buttons (linke Spalte)
messageArea.setId("batch-run-message-area");
messageArea.setEditable(false);
messageArea.setWrapText(true);
messageArea.setPrefRowCount(3);
messageArea.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MESSAGE_AREA));
// Hinweisbereich erst einblenden wenn eine Meldung vorliegt
messageArea.setVisible(false);
messageArea.setManaged(false);
@@ -590,12 +686,14 @@ public final class GuiBatchRunTab {
masterCheckBox.setId("batch-run-master-checkbox");
masterCheckBox.setOnAction(e -> handleMasterCheckBoxAction());
masterCheckBox.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MASTER_CHECKBOX));
checkboxCol.setGraphic(masterCheckBox);
checkboxCol.setCellFactory(col -> new CheckBoxCell());
checkboxCol.setEditable(true);
TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>("Status");
TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>();
iconCol.setGraphic(columnHeader("Status", GuiTooltipTexts.BATCHRUN_COL_STATUS));
iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon()));
iconCol.setPrefWidth(64);
iconCol.setCellFactory(col -> new TableCell<GuiBatchRunResultRow, String>() {
@@ -605,6 +703,7 @@ public final class GuiBatchRunTab {
if (empty || icon == null) {
setText(null);
setStyle(null);
setTooltip(null);
return;
}
setText(icon);
@@ -612,18 +711,26 @@ public final class GuiBatchRunTab {
GuiBatchRunResultRow data = tableRow != null ? tableRow.getItem() : null;
if (data != null && data.resetPending()) {
setStyle("-fx-text-fill: #1565c0; -fx-alignment: CENTER; -fx-font-size: 14;");
} else {
String color = data != null ? statusColor(data.status()) : "#000000";
setTooltip(new Tooltip(data.statusTooltip()));
} else if (data != null) {
// Farbe aus zentralem Mapping nie alleiniges Unterscheidungsmerkmal
String color = ProcessingStatusPresentation.cssColorFor(data.status());
setStyle("-fx-text-fill: " + color + "; -fx-alignment: CENTER; -fx-font-size: 14;");
setTooltip(new Tooltip(data.statusTooltip()));
} else {
setStyle("-fx-alignment: CENTER; -fx-font-size: 14;");
setTooltip(null);
}
}
});
TableColumn<GuiBatchRunResultRow, String> nameCol = new TableColumn<>("Originaldateiname");
TableColumn<GuiBatchRunResultRow, String> nameCol = new TableColumn<>();
nameCol.setGraphic(columnHeader("Originaldateiname", GuiTooltipTexts.BATCHRUN_COL_ORIGINALDATEINAME));
nameCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().originalFileName()));
nameCol.setPrefWidth(280);
TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>("Neuer Dateiname");
TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>();
newNameCol.setGraphic(columnHeader("Neuer Dateiname", GuiTooltipTexts.BATCHRUN_COL_NEUER_DATEINAME));
newNameCol.setCellValueFactory(data -> {
GuiBatchRunResultRow row = data.getValue();
if (row.resetPending()) {
@@ -633,14 +740,16 @@ public final class GuiBatchRunTab {
});
newNameCol.setPrefWidth(280);
TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>("Datum");
TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>();
dateCol.setGraphic(columnHeader("Datum", GuiTooltipTexts.BATCHRUN_COL_DATUM));
dateCol.setCellValueFactory(data -> new SimpleStringProperty(
data.getValue().resolvedDate()
.map(DateTimeFormatter.ISO_LOCAL_DATE::format)
.orElse(EMPTY_CELL_TEXT)));
dateCol.setPrefWidth(100);
TableColumn<GuiBatchRunResultRow, String> durationCol = new TableColumn<>("Dauer");
TableColumn<GuiBatchRunResultRow, String> durationCol = new TableColumn<>();
durationCol.setGraphic(columnHeader("Dauer", GuiTooltipTexts.BATCHRUN_COL_DAUER));
durationCol.setCellValueFactory(data -> new SimpleStringProperty(
formatDuration(data.getValue().processingDuration())));
durationCol.setPrefWidth(80);
@@ -716,7 +825,7 @@ public final class GuiBatchRunTab {
return;
}
fileNameEditor.discardChanges();
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung Benutzer hat verworfen");
LOG.debug(DIRTY_STATE_MSG);
}
// Neue Zeile laden
@@ -853,16 +962,35 @@ public final class GuiBatchRunTab {
*/
private void handleCopyResult(ManualFileCopyResult result, GuiBatchRunResultRow row) {
switch (result) {
case ManualFileCopySuccess success -> {
case ManualFileCopySuccess success -> applyCopySuccess(success, row);
case ManualFileCopyNoOpIdenticalTarget noOp -> applyCopyNoOpIdentical(noOp, row);
case ManualFileCopyDocumentNotFound notFound -> {
LOG.warn(COPY_FAILED_LOG, notFound.reason());
showMessage("Fehler: Dokument nicht gefunden " + notFound.reason());
}
case ManualFileCopyInvalidState invalidState -> {
LOG.warn(COPY_FAILED_LOG, invalidState.reason());
showMessage("Fehler: Ungültiger Dokumentstatus " + invalidState.reason());
}
case ManualFileCopyFileSystemFailure fsFail -> {
LOG.warn(COPY_FAILED_LOG, fsFail.message());
showMessage("Dateisystemfehler: " + fsFail.message());
}
case ManualFileCopyPersistenceFailure persistFail -> {
LOG.warn(COPY_FAILED_LOG, persistFail.message());
showMessage("Persistenzfehler (Zielkopie wurde zurückgerollt): " + persistFail.message());
}
}
}
private void applyCopySuccess(ManualFileCopySuccess success, GuiBatchRunResultRow row) {
LOG.info("Manuelle Dateikopie erfolgreich: {} → {} (Suffix: {})",
row.originalFileName(), success.appliedFileName(),
success.conflictSuffixApplied());
row.originalFileName(), success.appliedFileName(), success.conflictSuffixApplied());
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, success.appliedFileName());
currentlySelectedRow = updatedRow;
fileNameEditor.clearDirtyState();
upsertResultRowByFingerprint(updatedRow);
String targetFolder = targetFolderSupplier.get().orElse("");
fileNameEditor.loadSelection(updatedRow, targetFolder);
fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
String msg = "Datei kopiert und gespeichert: " + success.appliedFileName();
if (success.conflictSuffixApplied()) {
msg += " (Suffix wegen Namenskonflikt angehängt)";
@@ -870,37 +998,18 @@ public final class GuiBatchRunTab {
showMessage(msg);
refreshAggregateCountersFromItems();
}
case ManualFileCopyNoOpIdenticalTarget noOp -> {
private void applyCopyNoOpIdentical(ManualFileCopyNoOpIdenticalTarget noOp, GuiBatchRunResultRow row) {
LOG.info("Manuelle Dateikopie: identische Zieldatei {} bereits vorhanden kein Schreibvorgang.",
noOp.existingFileName());
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, noOp.existingFileName());
currentlySelectedRow = updatedRow;
fileNameEditor.clearDirtyState();
upsertResultRowByFingerprint(updatedRow);
String targetFolder = targetFolderSupplier.get().orElse("");
fileNameEditor.loadSelection(updatedRow, targetFolder);
fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
showMessage("Identische Datei bereits vorhanden Status auf SUCCESS gesetzt");
refreshAggregateCountersFromItems();
}
case ManualFileCopyDocumentNotFound notFound -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", notFound.reason());
showMessage("Fehler: Dokument nicht gefunden " + notFound.reason());
}
case ManualFileCopyInvalidState invalidState -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", invalidState.reason());
showMessage("Fehler: Ungültiger Dokumentstatus " + invalidState.reason());
}
case ManualFileCopyFileSystemFailure fsFail -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", fsFail.message());
showMessage("Dateisystemfehler: " + fsFail.message());
}
case ManualFileCopyPersistenceFailure persistFail -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", persistFail.message());
showMessage("Persistenzfehler (Zielkopie wurde zurückgerollt): "
+ persistFail.message());
}
}
}
/**
* Baut eine neue Zeilen-Sicht für ein Dokument, das per manueller Dateikopie auf
@@ -1001,24 +1110,24 @@ public final class GuiBatchRunTab {
noOp.existingFileName());
}
case ManualFileRenameDocumentNotFound notFound -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", notFound.reason());
LOG.warn(RENAME_FAILED_LOG, notFound.reason());
showMessage("Fehler: Dokument nicht gefunden " + notFound.reason());
}
case ManualFileRenameInvalidState invalidState -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", invalidState.reason());
LOG.warn(RENAME_FAILED_LOG, invalidState.reason());
showMessage("Fehler: Ungültiger Dokumentstatus " + invalidState.reason());
}
case ManualFileRenameSourceFileMissing sourceMissing -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}",
LOG.warn(RENAME_FAILED_LOG,
sourceMissing.expectedFileName());
showMessage("Zieldatei nicht gefunden Umbenennung nicht möglich");
}
case ManualFileRenameFileSystemFailure fsFail -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", fsFail.message());
LOG.warn(RENAME_FAILED_LOG, fsFail.message());
showMessage("Dateisystemfehler: " + fsFail.message());
}
case ManualFileRenamePersistenceFailure persistFail -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", persistFail.message());
LOG.warn(RENAME_FAILED_LOG, persistFail.message());
showMessage("Persistenzfehler (Dateisystem wurde zurückgerollt): "
+ persistFail.message());
}
@@ -1128,14 +1237,16 @@ public final class GuiBatchRunTab {
// Lauf-Steuerungs-Buttons
startButton.setId("batch-run-start");
startButton.setOnAction(event -> handleStart());
startButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STARTEN));
cancelButton.setId("batch-run-cancel");
cancelButton.setOnAction(event -> requestCancellation());
cancelButton.setDisable(true);
cancelButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ABBRECHEN));
HBox runButtonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
runButtonBar.setAlignment(Pos.CENTER_LEFT);
runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, 0, 0));
runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2.0, 0, 0, 0));
return runButtonBar;
}
@@ -1157,7 +1268,7 @@ public final class GuiBatchRunTab {
return;
}
fileNameEditor.discardChanges();
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung Benutzer hat verworfen");
LOG.debug(DIRTY_STATE_MSG);
}
if (!savedConfigurationReadyCheck.getAsBoolean()) {
showMessage(NO_SAVED_CONFIGURATION_HINT);
@@ -1179,6 +1290,7 @@ public final class GuiBatchRunTab {
messageArea.setVisible(false);
messageArea.setManaged(false);
messageArea.setStyle(null);
summaryBanner.clear();
resetMetrics();
updateCounterLabel();
progressBar.setProgress(0);
@@ -1210,7 +1322,7 @@ public final class GuiBatchRunTab {
return;
}
fileNameEditor.discardChanges();
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung Benutzer hat verworfen");
LOG.debug(DIRTY_STATE_MSG);
}
if (!savedConfigurationReadyCheck.getAsBoolean()) {
showMessage(NO_SAVED_CONFIGURATION_HINT);
@@ -1385,7 +1497,7 @@ public final class GuiBatchRunTab {
private void updateButtonStates() {
boolean running = runningProperty.get();
startButton.setDisable(running);
startButton.setDisable(running || schedulerActive);
if (!running) {
cancelButton.setDisable(true);
} else {
@@ -1419,14 +1531,21 @@ public final class GuiBatchRunTab {
// Statische Helfer
// -------------------------------------------------------------------------
private static String statusColor(DocumentCompletionStatus status) {
return switch (status) {
case SUCCESS -> "#2e7d32";
case FAILED_RETRYABLE -> "#e65100";
case FAILED_PERMANENT -> "#c62828";
case SKIPPED_ALREADY_PROCESSED -> "#1565c0";
case SKIPPED_FINAL_FAILURE -> "#757575";
};
// statusColor() wurde zugunsten von ProcessingStatusPresentation.cssColorFor() entfernt.
/**
* Erzeugt ein Label für den Spaltenkopf einer TableColumn mit Tooltip.
* Wird anstelle von {@code column.setText()} verwendet, da TableColumn
* kein direktes {@code setTooltip()} unterstützt.
*
* @param title sichtbarer Spaltentext; darf nicht leer sein
* @param tooltip Tooltip-Text; darf nicht leer sein
* @return ein Label mit gesetztem Tooltip
*/
private static Label columnHeader(String title, String tooltip) {
Label label = new Label(title);
label.setTooltip(new Tooltip(tooltip));
return label;
}
private static String formatDuration(Duration duration) {
@@ -1448,31 +1567,16 @@ public final class GuiBatchRunTab {
return builder.toString();
}
if (row.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED) {
builder.append('\n');
row.historicalContext().ifPresentOrElse(ctx -> {
ctx.lastSuccessInstant().ifPresentOrElse(
instant -> builder.append("Bereits erfolgreich verarbeitet am ")
.append(DETAIL_DATE_FORMAT.format(
instant.atZone(ZoneId.systemDefault())))
.append('.'),
() -> builder.append("Bereits erfolgreich verarbeitet."));
ctx.lastTargetFileName().ifPresent(name ->
builder.append('\n').append("Zieldatei: ").append(name).append('.'));
}, () -> builder.append("Bereits erfolgreich verarbeitet."));
return builder.toString();
return appendSkippedAlreadyProcessed(builder, row);
}
if (row.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE) {
builder.append('\n');
row.historicalContext().ifPresentOrElse(ctx ->
ctx.lastFailureInstant().ifPresentOrElse(
instant -> builder.append("Endg\u00fcltig fehlgeschlagen am ")
.append(DETAIL_DATE_FORMAT.format(
instant.atZone(ZoneId.systemDefault())))
.append(". Erneute Verarbeitung nur nach Reset m\u00f6glich."),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich.")),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
return appendSkippedFinalFailure(builder, row);
}
if (row.status() == DocumentCompletionStatus.FAILED_PERMANENT) {
builder.append('\n').append(ProcessingStatusPresentation.DETAIL_TEXT_FAILED_PERMANENT);
row.aiFailureMessage().ifPresent(msg ->
builder.append("\n\nFehlerdetail: ")
.append(AiFailureMessageTranslator.translate(msg)));
return builder.toString();
}
row.effectiveFileName()
@@ -1489,6 +1593,34 @@ public final class GuiBatchRunTab {
return builder.toString();
}
private static String appendSkippedAlreadyProcessed(StringBuilder builder, GuiBatchRunResultRow row) {
builder.append('\n');
row.historicalContext().ifPresentOrElse(ctx -> {
ctx.lastSuccessInstant().ifPresentOrElse(
instant -> builder.append("Bereits erfolgreich verarbeitet am ")
.append(DETAIL_DATE_FORMAT.format(instant.atZone(ZoneId.systemDefault())))
.append('.'),
() -> builder.append("Bereits erfolgreich verarbeitet."));
ctx.lastTargetFileName().ifPresent(name ->
builder.append('\n').append("Zieldatei: ").append(name).append('.'));
}, () -> builder.append("Bereits erfolgreich verarbeitet."));
return builder.toString();
}
private static String appendSkippedFinalFailure(StringBuilder builder, GuiBatchRunResultRow row) {
builder.append('\n');
row.historicalContext().ifPresentOrElse(ctx ->
ctx.lastFailureInstant().ifPresentOrElse(
instant -> builder.append("Endg\u00fcltig fehlgeschlagen am ")
.append(DETAIL_DATE_FORMAT.format(instant.atZone(ZoneId.systemDefault())))
.append(". Erneute Verarbeitung nur nach Reset m\u00f6glich."),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich.")),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
return builder.toString();
}
private static GuiBatchRunLaunchOutcome rejectingMiniLaunch(
Path p, Set<DocumentFingerprint> f,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver o,
@@ -1572,6 +1704,10 @@ public final class GuiBatchRunTab {
miniRunCompletedFingerprints = new HashSet<>();
}
selectedRows.clear();
// Summary-Banner aus der aktuellen Ergebnisliste aggregieren und anzeigen
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(resultItems);
summaryBanner.update(counts);
appendSummary(outcome);
updateButtonStates();
notifyRunStateChanged();
@@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -19,13 +20,21 @@ import org.apache.pdfbox.rendering.PDFRenderer;
import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import javafx.scene.Cursor;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
@@ -36,10 +45,21 @@ import javafx.scene.layout.VBox;
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
*
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
* in einer {@link ImageView} an. Die Anzeige ist vollständig eingepasst (fit-to-view):
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} sind an die Größe des
* in einer {@link ImageView} an. Im Fit-to-View-Modus (Standardzustand) sind
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} an die Größe des
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
* Seitenverhältnis. Es entstehen weder Scrollbalken noch Zoom-Artefakte.
* Seitenverhältnis. Die Seite füllt den verfügbaren Bereich ohne Scrollbalken.
*
* <p><strong>Mausrad-Zoom:</strong> Strg + Mausrad ändert den Zoomfaktor in Stufen von
* 10 % pro Raste (Bereich {@value #ZOOM_MIN}{@value #ZOOM_MAX}, d. h. 10 %500 %).
* Beim ersten manuellen Zoom wird der Fit-to-View-Modus verlassen und ein
* {@link ScrollPane} übernimmt das Scrollen. Das Laden einer neuen Datei setzt den
* Zoom automatisch auf Fit-to-View zurück.
*
* <p><strong>Grab &amp; Pan:</strong> Im manuellen Zoom-Modus kann die Vorschau durch
* Klicken und Ziehen (linke Maustaste) verschoben werden. Der Mauszeiger wechselt im
* Zoom-Modus auf {@link Cursor#OPEN_HAND} und während der Geste auf
* {@link Cursor#CLOSED_HAND}.
*
* <p>Das Laden der PDF-Datei und das Rendering einzelner Seiten erfolgt auf einem
* dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX
@@ -77,6 +97,18 @@ public final class PdfPreviewPane {
/** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
private static final float RENDER_DPI = 120f;
/** Minimaler Zoomfaktor (10 %). */
static final double ZOOM_MIN = 0.10;
/** Maximaler Zoomfaktor (500 %). */
static final double ZOOM_MAX = 5.00;
/** Zoom-Schrittgröße pro Mausrad-Raste (10 %). */
private static final double ZOOM_STEP = 0.10;
/** Typischer vertikaler Scroll-Delta pro Mausrad-Raste. */
private static final double ZOOM_NOTCH_THRESHOLD = 40.0;
private final VBox root = new VBox(4);
private final StackPane viewStack = new StackPane();
private final ImageView imageView = new ImageView();
@@ -86,6 +118,35 @@ public final class PdfPreviewPane {
private final Button prevButton = new Button("◀ Vorherige");
private final Button nextButton = new Button("Nächste ▶");
private final Label sectionTitle = new Label("PDF-Vorschau");
private final ScrollPane scrollPane = new ScrollPane(viewStack);
/** Aktueller Zoomfaktor; 1.0 entspricht der natürlichen Viewport-Breite. */
private double zoomLevel = 1.0;
/** Akkumulator für sub-Rasten-Scroll-Deltas. */
private double zoomAccumulator = 0.0;
/**
* Referenzbreite für die manuelle Zoom-Skalierung; gilt
* {@code imageView.fitWidth = naturalViewportWidth × zoomLevel} im manuellen
* Zoom-Modus. Beim Verlassen des Fit-Modus wird der Wert auf die natürliche
* Bildbreite gesetzt, sodass {@code zoomLevel = 1.0} der pixel-genauen
* Originalgröße entspricht und {@code zoomLevel} damit gleich dem visuellen
* Skalierungsfaktor ist. {@code 0.0} bedeutet Fit-to-View-Modus ist aktiv.
*/
private double naturalViewportWidth = 0.0;
/** X-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
private double panStartX = -1;
/** Y-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
private double panStartY = -1;
/** Horizontaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
private double panStartHvalue = 0.0;
/** Vertikaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
private double panStartVvalue = 0.0;
/**
* Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
@@ -110,18 +171,18 @@ public final class PdfPreviewPane {
/**
* Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread.
* {@code null} wenn kein Dokument geöffnet ist.
* Leerer Referenzwert wenn kein Dokument geöffnet ist.
*/
private volatile PDDocument currentDocument = null;
private final AtomicReference<PDDocument> currentDocument = new AtomicReference<>();
/**
* Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread.
* {@code null} wenn kein Dokument geöffnet ist.
* Leerer Referenzwert wenn kein Dokument geöffnet ist.
*/
private volatile PDFRenderer currentRenderer = null;
private final AtomicReference<PDFRenderer> currentRenderer = new AtomicReference<>();
/** Aktuell geladene Quelldatei; null wenn keine Selektion vorliegt. */
private volatile Path currentSourceFile = null;
/** Aktuell geladene Quelldatei; leerer Referenzwert wenn keine Selektion vorliegt. */
private final AtomicReference<Path> currentSourceFile = new AtomicReference<>();
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
private volatile int currentPage = 0;
@@ -162,13 +223,48 @@ public final class PdfPreviewPane {
StackPane.setAlignment(imageView, Pos.CENTER);
StackPane.setAlignment(overlayLabel, Pos.CENTER);
StackPane.setAlignment(progressIndicator, Pos.CENTER);
VBox.setVgrow(viewStack, Priority.ALWAYS);
scrollPane.setId("pdf-preview-scroll-pane");
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
// 32c: Verhindert, dass ScrollPane und StackPane beim manuellen Zoom mitwachsen
scrollPane.setPrefSize(0, 0);
viewStack.setMinSize(0, 0);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
// Strg + Mausrad Zoom; ohne Strg normales Scrollen
scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
if (event.isControlDown()) {
accumulateAndApplyZoomDelta(event.getDeltaY());
event.consume();
}
});
// Grab & Pan im manuellen Zoom-Modus mit Maus verschiebbar
viewStack.addEventHandler(MouseEvent.MOUSE_PRESSED, this::onPanMousePressed);
viewStack.addEventHandler(MouseEvent.MOUSE_DRAGGED, this::onPanMouseDragged);
viewStack.addEventHandler(MouseEvent.MOUSE_RELEASED, this::onPanMouseReleased);
// viewStack ist immer mindestens so groß wie der Viewport. Ist der Inhalt
// (ImageView) kleiner als der Viewport, sorgt diese Mindestgröße zusammen
// mit StackPane.Pos.CENTER dafür, dass die ImageView automatisch zentriert
// wird ohne manuelle setHvalue/setVvalue-Eingriffe. Ist der Inhalt größer,
// bleibt die Mindestgröße wirkungslos und der ScrollPane scrollt normal.
scrollPane.viewportBoundsProperty().addListener((obs, old, bounds) -> {
viewStack.setMinWidth(bounds.getWidth());
viewStack.setMinHeight(bounds.getHeight());
});
prevButton.setId("pdf-preview-prev-button");
prevButton.setOnAction(e -> navigateToPreviousPage());
Tooltip prevTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_VORHERIGE_SEITE);
prevTooltip.setShowDelay(Duration.millis(300));
prevButton.setTooltip(prevTooltip);
nextButton.setId("pdf-preview-next-button");
nextButton.setOnAction(e -> navigateToNextPage());
Tooltip nextTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_NAECHSTE_SEITE);
nextTooltip.setShowDelay(Duration.millis(300));
nextButton.setTooltip(nextTooltip);
pageLabel.setId("pdf-preview-page-label");
pageLabel.setStyle("-fx-text-fill: #555555;");
@@ -177,7 +273,7 @@ public final class PdfPreviewPane {
navBar.setAlignment(Pos.CENTER);
navBar.setPadding(new Insets(4, 0, 4, 0));
root.getChildren().addAll(sectionTitle, viewStack, navBar);
root.getChildren().addAll(sectionTitle, scrollPane, navBar);
root.setPadding(new Insets(4, 0, 0, 0));
showPlaceholder();
@@ -208,10 +304,11 @@ public final class PdfPreviewPane {
clear();
return;
}
currentSourceFile = sourceFile;
currentSourceFile.set(sourceFile);
currentPage = 0;
totalPages = -1;
pageCache.clear();
resetToFitView();
requestLoad(sourceFile);
}
@@ -222,7 +319,7 @@ public final class PdfPreviewPane {
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clear() {
currentSourceFile = null;
currentSourceFile.set(null);
currentPage = 0;
totalPages = -1;
pageCache.clear();
@@ -230,6 +327,7 @@ public final class PdfPreviewPane {
currentRequestSequence.incrementAndGet();
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
executor.submit(this::closeCurrentDocumentOnWorker);
resetToFitView();
imageView.setImage(null);
showPlaceholder();
updateNavigationButtons();
@@ -287,6 +385,16 @@ public final class PdfPreviewPane {
return progressIndicator;
}
/** Visible for tests. */
ScrollPane scrollPane() {
return scrollPane;
}
/** Visible for tests. */
double zoomLevel() {
return zoomLevel;
}
// --- Navigation -----------------------------------------------------------
private void navigateToPreviousPage() {
@@ -365,12 +473,13 @@ public final class PdfPreviewPane {
try {
PDDocument doc = Loader.loadPDF(ioFile);
currentDocument = doc;
currentRenderer = new PDFRenderer(doc);
currentDocument.set(doc);
PDFRenderer renderer = new PDFRenderer(doc);
currentRenderer.set(renderer);
int pages = Math.max(1, doc.getNumberOfPages());
BufferedImage buffered =
currentRenderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
renderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
final int totalPagesFinal = pages;
@@ -406,7 +515,7 @@ public final class PdfPreviewPane {
* @param seq die Sequenznummer dieser Anforderung
*/
private void renderPageOnWorker(int page, long seq) {
PDFRenderer renderer = currentRenderer;
PDFRenderer renderer = currentRenderer.get();
if (renderer == null) {
// Dokument wurde zwischenzeitlich geschlossen nichts zu tun
return;
@@ -435,9 +544,8 @@ public final class PdfPreviewPane {
* auf dem Worker-Thread und ist idempotent.
*/
private void closeCurrentDocumentOnWorker() {
PDDocument doc = currentDocument;
currentDocument = null;
currentRenderer = null;
PDDocument doc = currentDocument.getAndSet(null);
currentRenderer.set(null);
if (doc != null) {
try {
doc.close();
@@ -463,6 +571,217 @@ public final class PdfPreviewPane {
});
}
// --- Grab & Pan -----------------------------------------------------------
/**
* Startet die Pan-Geste. Speichert die Startposition und den aktuellen Scroll-Zustand.
* Nur aktiv wenn der manuelle Zoom-Modus eingeschaltet ist.
*
* @param event das Maus-Pressed-Ereignis
*/
private void onPanMousePressed(MouseEvent event) {
if (scrollPane.isFitToWidth()) {
return; // Im Fit-Modus kein Pan nötig
}
panStartX = event.getScreenX();
panStartY = event.getScreenY();
panStartHvalue = scrollPane.getHvalue();
panStartVvalue = scrollPane.getVvalue();
viewStack.setCursor(Cursor.CLOSED_HAND);
event.consume();
}
/**
* Verschiebt den Viewport relativ zur Startposition der Pan-Geste.
* Die Scrolldelta wird auf die scrollbaren Bereiche des Inhalts normiert.
*
* @param event das Maus-Dragged-Ereignis
*/
private void onPanMouseDragged(MouseEvent event) {
if (panStartX < 0 || scrollPane.isFitToWidth()) {
return;
}
double dx = event.getScreenX() - panStartX;
double dy = event.getScreenY() - panStartY;
Bounds viewport = scrollPane.getViewportBounds();
double contentWidth = viewStack.getWidth();
double contentHeight = viewStack.getHeight();
double viewportWidth = viewport != null ? viewport.getWidth() : 0;
double viewportHeight = viewport != null ? viewport.getHeight() : 0;
double scrollableWidth = contentWidth - viewportWidth;
double scrollableHeight = contentHeight - viewportHeight;
if (scrollableWidth > 0) {
double newHval = panStartHvalue - dx / scrollableWidth;
scrollPane.setHvalue(Math.max(0, Math.min(1, newHval)));
}
if (scrollableHeight > 0) {
double newVval = panStartVvalue - dy / scrollableHeight;
scrollPane.setVvalue(Math.max(0, Math.min(1, newVval)));
}
event.consume();
}
/**
* Beendet die Pan-Geste und stellt den OPEN_HAND-Mauszeiger wieder her.
*
* @param event das Maus-Released-Ereignis
*/
private void onPanMouseReleased(MouseEvent event) {
panStartX = -1;
panStartY = -1;
if (!scrollPane.isFitToWidth()) {
viewStack.setCursor(Cursor.OPEN_HAND);
}
event.consume();
}
// --- Zoom -----------------------------------------------------------------
/**
* Akkumuliert den Scroll-Delta und wendet den Zoom schrittweise an.
* Pro Raste (ca. {@value #ZOOM_NOTCH_THRESHOLD} Einheiten) ändert sich der Zoom
* um {@value #ZOOM_STEP}. Pro ScrollEvent wird maximal eine Zoom-Stufe angewendet.
*
* <p>Der Rohwert von {@code deltaY} wird vor der Akkumulation auf einen
* Notch-Wert ({@value #ZOOM_NOTCH_THRESHOLD}) begrenzt. Plattformspezifische
* Scroll-Multiplikatoren (z. B. Windows-Mausgeschwindigkeit, hohe DPI-Mäuse)
* können sonst Werte wie 120 oder mehr pro Raste liefern, was einen
* Akkumulator-Überlauf in Folge-Events verursacht.
*
* @param deltaY vertikaler Scroll-Delta des {@link ScrollEvent}
*/
private void accumulateAndApplyZoomDelta(double deltaY) {
// Normierung: maximal einen Notch-Wert pro Event akkumulieren, um
// plattformspezifische deltaY-Überhöhungen (z. B. 120 statt 40) abzufangen
double capped = Math.signum(deltaY) * Math.min(Math.abs(deltaY), ZOOM_NOTCH_THRESHOLD);
zoomAccumulator += capped;
if (zoomAccumulator >= ZOOM_NOTCH_THRESHOLD) {
zoomAccumulator -= ZOOM_NOTCH_THRESHOLD;
applyZoom(Math.min(ZOOM_MAX, zoomLevel + ZOOM_STEP));
} else if (zoomAccumulator <= -ZOOM_NOTCH_THRESHOLD) {
zoomAccumulator += ZOOM_NOTCH_THRESHOLD;
applyZoom(Math.max(ZOOM_MIN, zoomLevel - ZOOM_STEP));
}
}
/**
* Setzt den Zoomfaktor und verlässt beim ersten Aufruf den Fit-to-View-Modus.
* <p>
* Beim ersten Aufruf (Wechsel aus dem Fit-Modus) wird {@code zoomLevel} auf
* den aktuellen visuellen Skalierungsfaktor kalibriert: aktuelle visuelle
* Breite der ImageView (mit {@code preserveRatio} bereits aspekt-korrekt
* verkleinert) geteilt durch die natürliche Bildbreite. Damit entspricht
* {@code zoomLevel = 1.0} der pixel-genauen Originalgröße, und der erste
* Zoom-Schritt addiert sich auf den realen Skalierungsfaktor. Ohne diese
* Kalibrierung springt die ImageView abrupt auf {@code Viewport-Breite × 1.10},
* weil im Fit-Modus die {@code fitHeight}-Bindung das Bild aspekt-erhaltend
* deutlich kleiner zwingt als {@code naturalViewportWidth × 1.0} ergibt.
* Da der Caller den Delta-Schritt auf dem alten {@code zoomLevel = 1.0}
* berechnet hat, wird er nach der Kalibrierung auf den neuen, kalibrierten
* {@code zoomLevel} re-appliziert.
* <p>
* Beim Wechsel aus dem Fit-to-View-Modus wird die Ansicht auf die Bildmitte
* zentriert (H/V = 0.5). Bei weiteren Zoom-Schritten bleibt die aktuelle
* Scrollposition erhalten. Ein {@code layout()}-Aufruf vor der
* Positionswiederherstellung stellt sicher, dass die neuen Inhaltsgrenzen
* bereits berechnet sind.
*
* @param newZoom gewünschter Zoomfaktor, wird auf [{@link #ZOOM_MIN}, {@link #ZOOM_MAX}] begrenzt
*/
private void applyZoom(double newZoom) {
double effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom));
boolean wasInFitMode = scrollPane.isFitToWidth();
if (wasInFitMode) {
Image image = imageView.getImage();
if (image == null || image.getWidth() <= 0) {
return; // Kein Bild Zoom-Kalibrierung nicht möglich
}
double naturalImageWidth = image.getWidth();
double currentVisualWidth = imageView.getBoundsInLocal().getWidth();
if (currentVisualWidth <= 0) {
Bounds viewport = scrollPane.getViewportBounds();
currentVisualWidth = viewport != null ? viewport.getWidth() : viewStack.getWidth();
if (currentVisualWidth <= 0) {
return; // Layout noch nicht abgeschlossen
}
}
// Vom Caller intendierten Delta-Schritt vor der Kalibrierung sichern
double requestedDelta = newZoom - zoomLevel;
// zoomLevel auf den aktuellen visuellen Skalierungsfaktor kalibrieren
naturalViewportWidth = naturalImageWidth;
zoomLevel = currentVisualWidth / naturalImageWidth;
// effective neu berechnen, weil zoomLevel sich geändert hat
effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoomLevel + requestedDelta));
scrollPane.setFitToWidth(false);
scrollPane.setFitToHeight(false);
imageView.fitWidthProperty().unbind();
imageView.fitHeightProperty().unbind();
// Mauszeiger signalisiert Pan-Modus
viewStack.setCursor(Cursor.OPEN_HAND);
}
if (effective == zoomLevel) {
return;
}
zoomLevel = effective;
imageView.setFitWidth(naturalViewportWidth * zoomLevel);
imageView.setFitHeight(0);
// Keine manuellen setHvalue/setVvalue-Eingriffe nötig: viewStack hat
// dank des viewportBoundsProperty-Listeners im Konstruktor mindestens
// Viewport-Größe, und Pos.CENTER sorgt für automatische Zentrierung,
// wenn der Inhalt kleiner als der Viewport ist.
}
/**
* Setzt Zoom, Akkumulator und Pan-Zustand zurück und reaktiviert den Fit-to-View-Modus.
* Wird beim Laden einer neuen Datei und beim Leeren der Komponente aufgerufen.
*
* <p>Reihenfolge der Aktionen ist kritisch:
* <ol>
* <li>{@code setFitToWidth(true)} und {@code setFitToHeight(true)} sofort,
* damit der nächste Layout-Pass den {@code viewStack} auf Viewport-Größe
* zurückrechnet.</li>
* <li>Property-Bindungen und H/V-Reset im {@code Platform.runLater}, damit
* sie auf die bereits zurückgerechneten {@code viewStack}-Dimensionen
* wirken und nicht auf die noch zoom-große Breite.</li>
* </ol>
* Ohne diese Reihenfolge würden die Bindungen die imageView kurz an die
* Zoom-Größe koppeln, und ein verbleibender H/V-Wert aus dem Pan-/Zoom-Modus
* (z. B. {@code hvalue=0.0} nach Pan zum linken Rand) würde die PDF wegen
* kleinster Rounding-/Border-Differenzen links/oben bündig statt zentriert
* anzeigen, obwohl der ScrollPane fit-aktiv ist.
*/
private void resetToFitView() {
zoomLevel = 1.0;
zoomAccumulator = 0.0;
naturalViewportWidth = 0.0;
// Pan-Zustand und Mauszeiger zurücksetzen
panStartX = -1;
panStartY = -1;
viewStack.setCursor(null);
if (!scrollPane.isFitToWidth()) {
// 1. ScrollPane in Fit-Modus schalten, damit der nächste Layout-Pass
// den viewStack auf Viewport-Größe zurückrechnet
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
// 2. Bindings erst nach abgeschlossenem Layout-Pass, damit sie auf
// die zurückgerechneten viewStack-Dimensionen wirken
Platform.runLater(() -> {
imageView.fitWidthProperty().bind(viewStack.widthProperty());
imageView.fitHeightProperty().bind(viewStack.heightProperty());
});
}
}
// --- UI-Zustandshelfer ---------------------------------------------------
private void showPlaceholder() {
@@ -506,7 +825,7 @@ public final class PdfPreviewPane {
}
private void updateNavigationButtons() {
boolean canNavigate = enabled && currentSourceFile != null && totalPages > 0;
boolean canNavigate = enabled && currentSourceFile.get() != null && totalPages > 0;
prevButton.setDisable(!canNavigate || currentPage <= 1);
nextButton.setDisable(!canNavigate || currentPage >= totalPages);
}
@@ -0,0 +1,287 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
/**
* Zentrale Mapping-Klasse für die visuelle Darstellung von Verarbeitungsstatus in der GUI.
* <p>
* Diese Klasse ist die einzige autoritative Quelle für Status-Icons, CSS-Farben,
* Tooltip-Texte und Summary-Kategorielabels aller {@link DocumentCompletionStatus}-Werte.
* Alle Anzeigeorte im GUI-Adapter (Ergebnistabelle, Detailbereich, Summary-Banner)
* beziehen ihre Darstellungsinformationen ausschließlich über diese Klasse.
* <p>
* Farbe ist niemals das einzige Unterscheidungsmerkmal: Icon und Tooltip-Text beschreiben
* den Status vollständig auch ohne Farb­wahrnehmung.
* <p>
* Diese Klasse enthält keine JavaFX-Typen; sie ist rein datenhaltend und zustandslos.
* Alle Methoden sind statisch.
*/
public final class ProcessingStatusPresentation {
private static final String STATUS_NOT_NULL = "status darf nicht null sein";
// -------------------------------------------------------------------------
// Icons (Unicode-Zeichen, zuverlässig darstellbar unter Windows 10+)
// -------------------------------------------------------------------------
/** Icon für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String ICON_SUCCESS = ""; // CHECK MARK
/** Icon für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String ICON_FAILED_RETRYABLE = ""; // CLOCKWISE OPEN CIRCLE ARROW
/** Icon für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String ICON_FAILED_PERMANENT = "×"; // MULTIPLICATION SIGN
/** Icon für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String ICON_SKIPPED_ALREADY_PROCESSED = ""; // IDENTICAL TO
/** Icon für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String ICON_SKIPPED_FINAL_FAILURE = ""; // CIRCLED DIVISION SLASH
// -------------------------------------------------------------------------
// CSS-Farben (Hex-Strings für JavaFX setStyle)
// -------------------------------------------------------------------------
/** CSS-Farbe für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String COLOR_SUCCESS = "#2e7d32"; // Grün
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String COLOR_FAILED_RETRYABLE = "#d98200"; // Orange
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String COLOR_FAILED_PERMANENT = "#c62828"; // Rot
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String COLOR_SKIPPED_ALREADY_PROCESSED = "#757575"; // Grau
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String COLOR_SKIPPED_FINAL_FAILURE = "#424242"; // Dunkelgrau
// -------------------------------------------------------------------------
// Tooltip-Texte (deutsche Benutzertexte, gemäß Spezifikation)
// -------------------------------------------------------------------------
/** Tooltip für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String TOOLTIP_SUCCESS =
"Erfolgreich verarbeitet und umbenannt.";
/** Tooltip für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String TOOLTIP_FAILED_RETRYABLE =
"Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht.";
/** Tooltip für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String TOOLTIP_FAILED_PERMANENT =
"Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), Passwortschutz "
+ "oder beschädigte Datei. Kein weiterer automatischer Versuch.";
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String TOOLTIP_SKIPPED_ALREADY_PROCESSED =
"Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String TOOLTIP_SKIPPED_FINAL_FAILURE =
"Endgültig übersprungen nach wiederholten Fehlern.";
// -------------------------------------------------------------------------
// Detailtext für FAILED_PERMANENT (Erklärung im Detailbereich)
// -------------------------------------------------------------------------
/**
* Erweiterter Erklärungstext, der im Detailbereich bei dauerhaft fehlgeschlagenen
* Dokumenten angezeigt wird.
*/
public static final String DETAIL_TEXT_FAILED_PERMANENT =
"Diese Datei kann nicht verarbeitet werden. Mögliche Ursachen: "
+ "kein lesbarer Text (z. B. gescanntes Foto ohne OCR), Passwortschutz "
+ "oder beschädigte Datei. "
+ "Sie können den Status manuell zurücksetzen, wenn Sie die Ursache behoben haben.";
// -------------------------------------------------------------------------
// Summary-Kategorielabels
// -------------------------------------------------------------------------
/** Summary-Kategorie für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String SUMMARY_CATEGORY_SUCCESS = "erfolgreich";
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String SUMMARY_CATEGORY_FAILED_RETRYABLE = "wird wiederholt";
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String SUMMARY_CATEGORY_FAILED_PERMANENT = "fehlgeschlagen";
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED = "übersprungen";
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE = "endgültig übersprungen";
// -------------------------------------------------------------------------
// Record-Typ für gebündelte Darstellungsinformationen
// -------------------------------------------------------------------------
/**
* Gebündelte visuelle Darstellungsinformationen für einen Verarbeitungsstatus.
*
* @param icon Unicode-Zeichen als Status-Icon; nie leer
* @param cssColor CSS-Hex-Farbe für das Icon, z. B. {@code "#2e7d32"}; nie leer
* @param tooltipText Deutschsprachiger Tooltip-Text; nie leer
* @param summaryCategoryLabel Kategorie-Bezeichnung für das Summary-Banner; nie leer
*/
public record StatusVisuals(
String icon,
String cssColor,
String tooltipText,
String summaryCategoryLabel) {
/**
* Kompakter Konstruktor zur Pflichtfeld-Validierung.
*
* @throws NullPointerException wenn ein Feld {@code null} ist
* @throws IllegalArgumentException wenn ein String-Feld leer ist
*/
public StatusVisuals {
Objects.requireNonNull(icon, "icon muss gesetzt sein");
Objects.requireNonNull(cssColor, "cssColor muss gesetzt sein");
Objects.requireNonNull(tooltipText, "tooltipText muss gesetzt sein");
Objects.requireNonNull(summaryCategoryLabel, "summaryCategoryLabel muss gesetzt sein");
if (icon.isBlank()) throw new IllegalArgumentException("icon darf nicht leer sein");
if (cssColor.isBlank()) throw new IllegalArgumentException("cssColor darf nicht leer sein");
if (tooltipText.isBlank()) throw new IllegalArgumentException("tooltipText darf nicht leer sein");
if (summaryCategoryLabel.isBlank())
throw new IllegalArgumentException("summaryCategoryLabel darf nicht leer sein");
}
}
// -------------------------------------------------------------------------
// Zentrale Mapping-Methoden
// -------------------------------------------------------------------------
/**
* Liefert das Status-Icon für den angegebenen Verarbeitungsstatus.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return das zugehörige Unicode-Zeichen; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String iconFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> ICON_SUCCESS;
case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE;
case FAILED_PERMANENT -> ICON_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> ICON_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> ICON_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert die CSS-Hex-Farbe für das Status-Icon des angegebenen Verarbeitungsstatus.
* <p>
* Die Farbe ist nie das einzige Unterscheidungsmerkmal Icon und Tooltip-Text
* beschreiben den Status unabhängig von der Farbe eindeutig.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return die CSS-Hex-Farbe (z. B. {@code "#2e7d32"}); nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String cssColorFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> COLOR_SUCCESS;
case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE;
case FAILED_PERMANENT -> COLOR_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> COLOR_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> COLOR_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert den deutschsprachigen Tooltip-Text für den angegebenen Verarbeitungsstatus.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return der Tooltip-Text; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String tooltipFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> TOOLTIP_SUCCESS;
case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE;
case FAILED_PERMANENT -> TOOLTIP_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> TOOLTIP_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> TOOLTIP_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert die Summary-Kategorie-Bezeichnung für den angegebenen Verarbeitungsstatus.
* Diese Kategorie wird im Summary-Banner nach einem Lauf angezeigt.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return die Kategorienbezeichnung; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String summaryCategoryFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> SUMMARY_CATEGORY_SUCCESS;
case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE;
case FAILED_PERMANENT -> SUMMARY_CATEGORY_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert alle gebündelten visuellen Darstellungsinformationen für den angegebenen
* Verarbeitungsstatus in einem einzigen Objekt.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return ein befülltes {@link StatusVisuals}-Record; nie {@code null}
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static StatusVisuals visualsFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return new StatusVisuals(
iconFor(status),
cssColorFor(status),
tooltipFor(status),
summaryCategoryFor(status));
}
// -------------------------------------------------------------------------
// Mapping für ProcessingStatus (alle acht Domain-Statuswerte)
// -------------------------------------------------------------------------
/**
* Liefert den deutschsprachigen Anzeigetext mit Icon für den angegebenen
* Domain-Verarbeitungsstatus. Kein Enum-Rohname darf für Endnutzer sichtbar sein.
*
* @param status der Domain-Verarbeitungsstatus; darf nicht {@code null} sein
* @return der Anzeigetext mit vorangestelltem Icon; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String displayTextFor(ProcessingStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> "✓ Erfolgreich";
case FAILED_RETRYABLE -> "↻ Temporärer Fehler";
case FAILED_FINAL -> "× Dauerhaft fehlgeschlagen";
case SKIPPED_ALREADY_PROCESSED -> "≡ Bereits verarbeitet";
case SKIPPED_FINAL_FAILURE -> "⊘ Endgültig übersprungen";
case READY_FOR_AI -> "⟳ Wartet auf Verarbeitung";
case PROPOSAL_READY -> "◇ Vorschlag vorhanden";
case PROCESSING -> "▶ In Bearbeitung";
};
}
/** Nicht instanziierbar reine Utility-Klasse. */
private ProcessingStatusPresentation() {
throw new UnsupportedOperationException("Nicht instanziierbar");
}
}
@@ -29,10 +29,10 @@ public record GuiConfigurationEditorState(
* @param values current editable configuration values; must not be {@code null}
*/
public GuiConfigurationEditorState {
loadedFileSnapshot = loadedFileSnapshot == null ? Optional.empty() : loadedFileSnapshot;
loadedFileSnapshot = Objects.requireNonNullElse(loadedFileSnapshot, Optional.empty());
baselineValues = Objects.requireNonNull(baselineValues, "baselineValues must not be null");
values = Objects.requireNonNull(values, "values must not be null");
pendingMigrationMessage = pendingMigrationMessage == null ? Optional.empty() : pendingMigrationMessage;
pendingMigrationMessage = Objects.requireNonNullElse(pendingMigrationMessage, Optional.empty());
}
/**
@@ -39,7 +39,7 @@ public record GuiMessageEntry(
Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(text, "text must not be null");
Objects.requireNonNull(timestamp, "timestamp must not be null");
source = source == null ? Optional.empty() : source;
source = Objects.requireNonNullElse(source, Optional.empty());
}
/**
@@ -8,7 +8,9 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
/**
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
@@ -169,6 +171,26 @@ public final class GuiModelFieldContainer extends StackPane {
}
}
/**
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf beide internen Controls
* (ComboBox und TextField). Damit erscheint der Tooltip unabhängig davon, welches der
* beiden Controls gerade sichtbar ist.
* <p>
* Darf nur auf dem JavaFX Application Thread aufgerufen werden.
*
* @param tooltipText der anzuzeigende Tooltip-Text; darf nicht leer sein
*/
public void applyTooltip(String tooltipText) {
Objects.requireNonNull(tooltipText, "tooltipText darf nicht null sein");
Tooltip comboTooltip = new Tooltip(tooltipText);
comboTooltip.setShowDelay(Duration.millis(300));
comboBox.setTooltip(comboTooltip);
Tooltip textTooltip = new Tooltip(tooltipText);
textTooltip.setShowDelay(Duration.millis(300));
textField.setTooltip(textTooltip);
}
/**
* Returns the JavaFX node that represents this container and can be added to the scene graph.
*
@@ -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,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;
@@ -0,0 +1,53 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
/**
* GUI-interner Bridge-Port fuer die Verwaltung von Modell-Preisen.
*
* <p>Dieser Port ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Er ist eine modul-interne Bruecke, ueber die Bootstrap die SQLite-basierte
* Verwaltung der Tabelle {@code model_price} fuer den GUI-Tab bereitstellt,
* ohne dass der GUI-Adapter direkt auf Repository-Implementierungen zugreift.
*
* <p>Der Parameter {@code configFilePath} wird benoetigt, damit die
* Bootstrap-Implementierung die SQLite-Datenbank aus der aktuell geladenen
* Konfigurationsdatei ableiten kann, ohne den Pfad global zu speichern.
*
* <p><strong>Threading:</strong> Implementierungen muessen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden koennen. Aufrufe blockieren,
* bis das Ergebnis vollstaendig vorliegt.
*/
public interface GuiModelPriceManagementPort {
/**
* Liefert alle persistierten Modell-Preise.
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @return Liste der Modell-Preise; nie {@code null}
*/
List<ModelPriceView> findAll(Path configFilePath);
/**
* Sucht den Eintrag fuer (Provider, Modellname).
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @param provider Provider-Identifikator
* @param modelName Modellname
* @return Eintrag oder {@link Optional#empty()}
*/
Optional<ModelPriceView> findByProviderAndModelName(Path configFilePath, String provider, String modelName);
/**
* Persistiert ein {@link ModelPriceChangeSet} atomar.
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @param changeSet Sammlung aus Upserts und Loeschungen; nicht {@code null}
*/
void saveAllChanges(Path configFilePath, ModelPriceChangeSet changeSet);
}
@@ -0,0 +1,573 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import javafx.application.Platform;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* GUI-Tab fuer die Verwaltung der persistierten Modell-Preise.
*
* <p>Zeigt die Tabelle {@code model_price} aufbereitet als
* {@code $/1M Tokens} an. Eintraege bekannter Provider sind editierbar;
* Eintraege unbekannter Provider werden read-only mit Tooltip dargestellt
* und koennen lediglich geloescht werden.
*
* <p>Der Tab arbeitet ausschlie&szlig;lich gegen den
* {@link GuiModelPriceManagementPort}. Bootstrap verdrahtet den Port mit
* einer Implementierung, die anhand der aktuell geladenen Konfigurationsdatei
* eine SQLite-Verbindung aufbaut.
*
* <p>Threading: alle DB-Operationen laufen auf einem dedizierten
* Hintergrund-Worker-Thread; UI-Updates erfolgen ueber
* {@link Platform#runLater(Runnable)}.
*/
public final class GuiModelPricesTab {
private static final Logger LOG = LogManager.getLogger(GuiModelPricesTab.class);
private static final String TAB_TITLE = "Modell-Preise";
/** V3.3-Whitelist der unterstuetzten Provider. */
public static final List<String> SUPPORTED_PROVIDERS = List.of("openai-compatible", "claude");
private static final BigDecimal NANO_TO_USD_PER_MILLION = new BigDecimal("1000000000")
.divide(new BigDecimal("1000000"));
private final Tab tab = new Tab(TAB_TITLE);
private final TableView<EditableEntry> tableView = new TableView<>();
private final ObservableList<EditableEntry> rows = FXCollections.observableArrayList();
private final Label statusLabel = new Label();
private final Button addButton = new Button("Modell hinzufuegen");
private final Button saveButton = new Button("Speichern");
private final Button reloadButton = new Button("Neu laden");
private final GuiModelPriceManagementPort port;
private final Supplier<Optional<Path>> configPathSupplier;
private final Set<ModelPriceKey> originalKeys = new HashSet<>();
private final List<ModelPriceKey> pendingDeletions = new ArrayList<>();
private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "gui-model-prices");
t.setDaemon(true);
return t;
});
/**
* Erstellt den Tab und verdrahtet die Bedienelemente.
*
* @param port Bridge-Port fuer DB-Zugriff; darf {@code null} sein (Tab zeigt dann Hinweis)
* @param configPathSupplier Liefert den aktuell geladenen Konfigurationspfad oder leer; nicht {@code null}
*/
public GuiModelPricesTab(GuiModelPriceManagementPort port,
Supplier<Optional<Path>> configPathSupplier) {
this.port = port;
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier");
tab.setClosable(false);
buildUi();
updateButtonStates();
}
/**
* Liefert den JavaFX-Tab-Knoten.
*
* @return der Tab; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Benachrichtigt den Tab ueber eine geaenderte Konfiguration.
*
* <p>Aktualisiert den Aktivierungsstatus der Buttons anhand des aktuell vom
* {@code configPathSupplier} gelieferten Pfads. Muss auf dem JavaFX Application Thread
* aufgerufen werden.
*/
public void notifyConfigurationChanged() {
updateButtonStates();
}
/**
* Triggert ein Neuladen der Tabelle aus der aktuell geladenen Konfiguration.
*/
public void reloadFromCurrentConfig() {
Optional<Path> currentPath = configPathSupplier.get();
if (port == null || currentPath.isEmpty()) {
Platform.runLater(() -> {
rows.clear();
pendingDeletions.clear();
originalKeys.clear();
statusLabel.setText("Bitte zuerst eine Konfigurationsdatei laden.");
updateButtonStates();
});
return;
}
Path configPath = currentPath.get();
statusLabel.setText("Lade Modell-Preise ...");
workerExecutor.submit(() -> {
try {
List<ModelPriceView> views = port.findAll(configPath);
Platform.runLater(() -> applyLoadedRows(views));
} catch (RuntimeException ex) {
LOG.error("Modell-Preise konnten nicht geladen werden: {}", ex.getMessage(), ex);
Platform.runLater(() -> statusLabel.setText("Fehler beim Laden: " + ex.getMessage()));
}
});
}
private void applyLoadedRows(List<ModelPriceView> views) {
rows.clear();
pendingDeletions.clear();
originalKeys.clear();
for (ModelPriceView view : views) {
rows.add(EditableEntry.fromView(view));
originalKeys.add(new ModelPriceKey(view.provider(), view.modelName()));
}
statusLabel.setText("Geladen: " + views.size() + " Eintraege.");
updateButtonStates();
}
private void buildUi() {
tableView.setItems(rows);
tableView.setEditable(true);
tableView.setPlaceholder(new Label("Keine Modell-Preise vorhanden."));
TableColumn<EditableEntry, String> providerCol = new TableColumn<>("Provider");
providerCol.setCellValueFactory(c -> c.getValue().providerProperty);
providerCol.setPrefWidth(150);
TableColumn<EditableEntry, String> modelCol = new TableColumn<>("Modellname");
modelCol.setCellValueFactory(c -> c.getValue().modelNameProperty);
modelCol.setPrefWidth(220);
TableColumn<EditableEntry, String> inCol = new TableColumn<>("In/1M USD");
inCol.setCellValueFactory(c -> c.getValue().inputPriceTextProperty);
inCol.setPrefWidth(120);
inCol.setCellFactory(col -> new PriceEditCell(true));
TableColumn<EditableEntry, String> outCol = new TableColumn<>("Out/1M USD");
outCol.setCellValueFactory(c -> c.getValue().outputPriceTextProperty);
outCol.setPrefWidth(120);
outCol.setCellFactory(col -> new PriceEditCell(false));
TableColumn<EditableEntry, String> currencyCol = new TableColumn<>("Waehrung");
currencyCol.setCellValueFactory(c -> c.getValue().currencyProperty);
currencyCol.setPrefWidth(80);
TableColumn<EditableEntry, String> updatedCol = new TableColumn<>("Letzte Aenderung");
updatedCol.setCellValueFactory(c -> c.getValue().updatedAtTextProperty);
updatedCol.setPrefWidth(180);
TableColumn<EditableEntry, Void> deleteCol = new TableColumn<>("Aktion");
deleteCol.setCellFactory(col -> new DeleteButtonCell());
deleteCol.setPrefWidth(80);
tableView.getColumns().setAll(List.of(providerCol, modelCol, inCol, outCol,
currencyCol, updatedCol, deleteCol));
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_LAST_COLUMN);
addButton.setOnAction(e -> openAddDialog());
saveButton.setOnAction(e -> handleSave());
reloadButton.setOnAction(e -> reloadFromCurrentConfig());
HBox buttonBar = new HBox(8, addButton, saveButton, reloadButton);
buttonBar.setAlignment(Pos.CENTER_LEFT);
VBox.setVgrow(tableView, Priority.ALWAYS);
VBox content = new VBox(8, tableView, buttonBar, statusLabel);
content.setPadding(new Insets(12));
tab.setContent(content);
statusLabel.setText("Klicken Sie auf \"Neu laden\", um die aktuellen Modell-Preise anzuzeigen.");
}
private void updateButtonStates() {
Optional<Path> path = configPathSupplier.get();
boolean active = port != null && path.isPresent();
addButton.setDisable(!active);
saveButton.setDisable(!active);
reloadButton.setDisable(!active);
}
private void openAddDialog() {
Dialog<EditableEntry> dialog = new Dialog<>();
dialog.setTitle("Modell hinzufuegen");
dialog.setHeaderText("Neuen Modell-Preis erfassen");
ChoiceBox<String> providerBox = new ChoiceBox<>(FXCollections.observableArrayList(SUPPORTED_PROVIDERS));
providerBox.getSelectionModel().selectFirst();
TextField modelField = new TextField();
TextField inputField = new TextField();
TextField outputField = new TextField();
GridPane grid = new GridPane();
grid.setHgap(8);
grid.setVgap(8);
grid.add(new Label("Provider"), 0, 0);
grid.add(providerBox, 1, 0);
grid.add(new Label("Modellname"), 0, 1);
grid.add(modelField, 1, 1);
grid.add(new Label("In/1M USD"), 0, 2);
grid.add(inputField, 1, 2);
grid.add(new Label("Out/1M USD"), 0, 3);
grid.add(outputField, 1, 3);
dialog.getDialogPane().setContent(grid);
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
dialog.setResultConverter(button -> {
if (button != ButtonType.OK) {
return null;
}
String provider = providerBox.getValue();
String modelName = modelField.getText() != null ? modelField.getText().trim() : "";
if (provider == null || modelName.isEmpty()) {
showError("Provider und Modellname sind Pflichtfelder.");
return null;
}
try {
long inputNano = parseUsdPerMillionToNano(inputField.getText());
long outputNano = parseUsdPerMillionToNano(outputField.getText());
EditableEntry entry = new EditableEntry(provider, modelName,
inputNano, outputNano, "USD", null, false, true);
return entry;
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
return null;
}
});
Optional<EditableEntry> result = dialog.showAndWait();
result.ifPresent(entry -> {
for (EditableEntry existing : rows) {
if (existing.providerProperty.get().equals(entry.providerProperty.get())
&& existing.modelNameProperty.get().equals(entry.modelNameProperty.get())) {
showError("Eintrag fuer Provider \"" + entry.providerProperty.get()
+ "\" und Modell \"" + entry.modelNameProperty.get()
+ "\" existiert bereits.");
return;
}
}
rows.add(entry);
statusLabel.setText("Neuer Eintrag vorgemerkt; bitte speichern.");
});
}
private void handleSave() {
Optional<Path> currentPath = configPathSupplier.get();
if (port == null || currentPath.isEmpty()) {
return;
}
ModelPriceChangeSet changeSet;
try {
changeSet = buildChangeSet();
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
return;
}
if (changeSet.isEmpty()) {
statusLabel.setText("Keine Aenderungen zu speichern.");
return;
}
Path configPath = currentPath.get();
statusLabel.setText("Speichere ...");
saveButton.setDisable(true);
workerExecutor.submit(() -> {
try {
port.saveAllChanges(configPath, changeSet);
Platform.runLater(() -> {
statusLabel.setText("Aenderungen gespeichert.");
reloadFromCurrentConfig();
});
} catch (RuntimeException ex) {
LOG.error("Modell-Preis-Speichern fehlgeschlagen: {}", ex.getMessage(), ex);
Platform.runLater(() -> {
statusLabel.setText("Fehler beim Speichern: " + ex.getMessage());
saveButton.setDisable(false);
});
}
});
}
private ModelPriceChangeSet buildChangeSet() {
List<ModelPriceEntry> upserts = new ArrayList<>();
Instant placeholder = Instant.now();
for (EditableEntry row : rows) {
if (!row.editable) {
continue;
}
if (!row.dirty && originalKeys.contains(
new ModelPriceKey(row.providerProperty.get(), row.modelNameProperty.get()))) {
continue;
}
try {
upserts.add(new ModelPriceEntry(
row.providerProperty.get(),
row.modelNameProperty.get(),
row.inputPriceNanoUsd,
row.outputPriceNanoUsd,
"USD",
placeholder));
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Eintrag (" + row.providerProperty.get()
+ ", " + row.modelNameProperty.get() + ") ungueltig: " + ex.getMessage());
}
}
return new ModelPriceChangeSet(upserts, List.copyOf(pendingDeletions));
}
private void showError(String message) {
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
alert.setHeaderText("Eingabefehler");
alert.showAndWait();
}
/**
* Konvertiert eine $/1M-Tokens-Eingabe in Nano-USD/Token.
*
* <p>Akzeptiert Komma oder Punkt als Dezimaltrenner. Maximal sechs
* Nachkommastellen sind erlaubt; mehr fuehrt zur
* {@link IllegalArgumentException}. Negative Werte und Nicht-Numerisches
* werden ebenfalls abgewiesen.
*
* @param raw Eingabetext
* @return umgerechneter Nano-USD-Wert
* @throws IllegalArgumentException bei ungueltiger Eingabe
*/
static long parseUsdPerMillionToNano(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("Preis darf nicht leer sein.");
}
String normalized = raw.trim().replace(',', '.');
BigDecimal value;
try {
value = new BigDecimal(normalized);
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException("Preis ist nicht numerisch: " + raw);
}
if (value.signum() < 0) {
throw new IllegalArgumentException("Preis darf nicht negativ sein.");
}
if (value.scale() > 6) {
throw new IllegalArgumentException("Maximal 6 Nachkommastellen erlaubt.");
}
BigDecimal nanoPerToken = value.multiply(BigDecimal.valueOf(1000L))
.setScale(0, RoundingMode.HALF_UP);
long nanoLong = nanoPerToken.longValueExact();
if (nanoLong > ModelPriceEntry.MAX_PRICE_PER_TOKEN_NANO_USD) {
throw new IllegalArgumentException("Preis ueberschreitet Maximum.");
}
return nanoLong;
}
/**
* Formatiert einen Nano-USD-Wert als $/1M-Tokens-Text.
*
* @param nanoPerToken Nano-USD pro Token
* @return Formatierter Text mit bis zu sechs Nachkommastellen
*/
static String formatNanoAsUsdPerMillion(long nanoPerToken) {
BigDecimal usdPerMillion = BigDecimal.valueOf(nanoPerToken)
.multiply(NANO_TO_USD_PER_MILLION)
.divide(new BigDecimal("1000000000"), 6, RoundingMode.HALF_UP)
.stripTrailingZeros();
return usdPerMillion.toPlainString();
}
/**
* Mutable Tabellenzeile. Kapselt View-Felder als Properties und einen
* Dirty-Flag fuer den ChangeSet-Bau.
*/
private static final class EditableEntry {
final SimpleStringProperty providerProperty;
final SimpleStringProperty modelNameProperty;
final SimpleStringProperty inputPriceTextProperty;
final SimpleStringProperty outputPriceTextProperty;
final SimpleStringProperty currencyProperty;
final SimpleStringProperty updatedAtTextProperty;
final SimpleObjectProperty<Boolean> invalidUpdatedAtProperty;
long inputPriceNanoUsd;
long outputPriceNanoUsd;
boolean editable;
boolean dirty;
EditableEntry(String provider, String modelName,
long inputNano, long outputNano, String currency,
Instant updatedAt, boolean invalidUpdatedAt, boolean editable) {
this.providerProperty = new SimpleStringProperty(provider);
this.modelNameProperty = new SimpleStringProperty(modelName);
this.inputPriceNanoUsd = inputNano;
this.outputPriceNanoUsd = outputNano;
this.inputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(inputNano));
this.outputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(outputNano));
this.currencyProperty = new SimpleStringProperty(currency);
this.updatedAtTextProperty = new SimpleStringProperty(
invalidUpdatedAt ? "ungueltig"
: updatedAt == null ? "" : DateTimeFormatter.ISO_INSTANT.format(updatedAt));
this.invalidUpdatedAtProperty = new SimpleObjectProperty<>(invalidUpdatedAt);
this.editable = editable;
this.dirty = false;
}
static EditableEntry fromView(ModelPriceView view) {
boolean editable = SUPPORTED_PROVIDERS.contains(view.provider());
EditableEntry entry = new EditableEntry(
view.provider(), view.modelName(),
view.priceInputPerTokenNanoUsd(), view.priceOutputPerTokenNanoUsd(),
view.currency(), view.updatedAt(), view.invalidUpdatedAt(), editable);
return entry;
}
}
/**
* Editierbare Zelle fuer Input-/Output-Preisfelder.
*/
private final class PriceEditCell extends TableCell<EditableEntry, String> {
private final TextField textField = new TextField();
private final boolean isInputColumn;
PriceEditCell(boolean isInputColumn) {
this.isInputColumn = isInputColumn;
textField.focusedProperty().addListener((obs, was, focused) -> {
if (!focused) {
commit();
}
});
textField.setOnAction(e -> commit());
}
private void commit() {
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row == null || !row.editable) {
return;
}
String text = textField.getText();
try {
long nano = parseUsdPerMillionToNano(text);
if (isInputColumn) {
if (row.inputPriceNanoUsd != nano) {
row.inputPriceNanoUsd = nano;
row.dirty = true;
}
row.inputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
} else {
if (row.outputPriceNanoUsd != nano) {
row.outputPriceNanoUsd = nano;
row.dirty = true;
}
row.outputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
}
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
String revert = isInputColumn
? formatNanoAsUsdPerMillion(row.inputPriceNanoUsd)
: formatNanoAsUsdPerMillion(row.outputPriceNanoUsd);
textField.setText(revert);
}
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
return;
}
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row != null && !row.editable) {
setText(item);
setGraphic(null);
setTooltip(new Tooltip("Unbekannter Provider Bearbeitung in V3.3 nicht unterstuetzt."));
return;
}
textField.setText(item);
setText(null);
setGraphic(textField);
}
}
/**
* Loesch-Button-Spalte mit Bestaetigungsdialog.
*/
private final class DeleteButtonCell extends TableCell<EditableEntry, Void> {
private final Button button = new Button("Loeschen");
DeleteButtonCell() {
button.setOnAction(e -> {
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row == null) {
return;
}
String message = String.format(Locale.GERMAN,
"Eintrag fuer Provider \"%s\" und Modell \"%s\" wirklich loeschen?",
row.providerProperty.get(), row.modelNameProperty.get());
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, message, ButtonType.OK, ButtonType.CANCEL);
alert.setHeaderText("Loeschen bestaetigen");
alert.showAndWait().ifPresent(button -> {
if (button == ButtonType.OK) {
ModelPriceKey key = new ModelPriceKey(
row.providerProperty.get(), row.modelNameProperty.get());
if (originalKeys.contains(key)) {
pendingDeletions.add(key);
}
rows.remove(row);
}
});
});
}
@Override
protected void updateItem(Void item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setGraphic(null);
} else {
setGraphic(button);
}
}
}
}
@@ -0,0 +1,9 @@
/**
* GUI-Bestandteile fuer die Verwaltung der persistierten Modell-Preise.
*
* <p>Enthaelt den Bridge-Port {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices
* .GuiModelPriceManagementPort} und den zugehoerigen Tab. Der Port wird von
* Bootstrap mit einer Lambda-Implementierung gefuellt, die anhand der aktuell
* geladenen Konfigurationsdatei eine SQLite-Repository-Instanz aufbaut.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
@@ -244,12 +244,21 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
assertEquals(2, workspace.tabPane().getTabs().size(),
"Configuration tab and processing-run tab must both be present");
assertEquals(6, workspace.tabPane().getTabs().size(),
"Configuration tab, processing-run tab, scheduler tab, history tab, "
+ "prompt editor tab and model-prices tab must all be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The first tab must use the configuration label");
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
"The second tab must host the processing-run view");
assertEquals("Scheduler", workspace.tabPane().getTabs().get(2).getText(),
"The third tab must host the scheduler control");
assertEquals("Verlauf", workspace.tabPane().getTabs().get(3).getText(),
"The fourth tab must host the history view");
assertEquals("Prompt", workspace.tabPane().getTabs().get(4).getText(),
"The fifth tab must host the prompt editor");
assertEquals("Modell-Preise", workspace.tabPane().getTabs().get(5).getText(),
"The sixth tab must host the model prices view");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),
@@ -415,7 +424,8 @@ class GuiAdapterSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -345,7 +345,8 @@ class GuiEditorFieldBindingTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -137,7 +137,8 @@ class GuiEditorIntegrationTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -287,7 +288,8 @@ class GuiEditorIntegrationTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -371,7 +373,8 @@ class GuiEditorIntegrationTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -208,7 +208,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -347,7 +348,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -471,7 +473,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -599,7 +602,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -698,7 +702,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -142,7 +142,8 @@ class GuiEditorValidationSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -272,7 +273,8 @@ class GuiEditorValidationSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -0,0 +1,202 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import javafx.application.Platform;
import javafx.scene.control.Tab;
/**
* Monocle-basierte Headless-Smoke-Tests für {@link GuiHistoryTab}.
* <p>
* Geprüfte Szenarien:
* <ul>
* <li>Tab wird mit Titel Verlauf" erstellt.</li>
* <li>Tab ist nicht schließbar.</li>
* <li>Ohne geladene Konfiguration bleibt die Übersicht leer (null-configPath).</li>
* <li>Mit leerem Übersichts-Port bleibt die Tabelle leer.</li>
* </ul>
*/
class GuiHistoryTabSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform muss innerhalb des Timeouts starten");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Gemeinsame Platform kein Platform.exit().
}
// =========================================================================
// Stubs
// =========================================================================
private static GuiHistoryOverviewPort emptyOverviewPort() {
return (configFilePath, query) ->
new HistoryOverviewResult(List.of(), false);
}
private static GuiHistoryDetailsPort emptyDetailsPort() {
return (configFilePath, fingerprint) -> Optional.empty();
}
private static GuiHistoryResetDocumentStatusPort noOpResetPort() {
return (configFilePath, fingerprint) -> { /* no-op */ };
}
private static GuiDeleteDocumentHistoryPort noOpDeletePort() {
return (configFilePath, fingerprint) -> { /* no-op */ };
}
private static GuiHistoryTab buildTab(Path configPath) {
return new GuiHistoryTab(
emptyOverviewPort(),
emptyDetailsPort(),
noOpResetPort(),
noOpDeletePort(),
() -> false,
() -> configPath);
}
// =========================================================================
// Tests
// =========================================================================
@Test
void tab_shouldHaveTitleVerlauf() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<Tab> tabRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
GuiHistoryTab historyTab = buildTab(null);
tabRef.set(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
assertEquals("Verlauf", tabRef.get().getText(), "Tab-Titel muss 'Verlauf' sein");
}
@Test
void tab_shouldNotBeClosable() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean closableRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
GuiHistoryTab historyTab = buildTab(null);
closableRef.set(historyTab.tab().isClosable());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(closableRef.get(), "Tab darf nicht schließbar sein");
}
@Test
void construction_withNullConfigPath_doesNotThrow() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
// Konstruktion mit null-configPath-Supplier muss möglich sein
GuiHistoryTab historyTab = buildTab(null);
assertNotNull(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
}
@Test
void construction_withConfigPath_doesNotThrow() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
Path dummyPath = Paths.get("config/application.properties");
GuiHistoryTab historyTab = buildTab(dummyPath);
assertNotNull(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
}
}
@@ -336,7 +336,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -478,7 +479,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -565,7 +567,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -888,7 +891,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -529,7 +529,8 @@ class GuiModelCatalogSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -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());
}
}
@@ -0,0 +1,341 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.application.Platform;
/**
* Tests für die Statuszeilen-Komponente {@link GuiStatusBar}.
* <p>
* Überprüft die Versionsanzeige, den Provider-Text und den Konfigurationspfad-Text
* in den verschiedenen Zuständen (ohne und mit geladener Konfiguration).
* <p>
* Die Tests laufen unter Monocle (Headless-JavaFX), da {@link GuiStatusBar} JavaFX-Controls erzeugt.
*/
class GuiStatusBarTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
/**
* Initialisiert die JavaFX-Plattform einmalig für alle Tests dieser Klasse.
*
* @throws InterruptedException falls der Thread beim Warten unterbrochen wird
*/
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch startLatch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
startLatch.countDown();
});
} catch (IllegalStateException alreadyInitialized) {
// JavaFX wurde bereits durch einen anderen Test gestartet
PLATFORM_STARTED.set(true);
startLatch.countDown();
}
assertTrue(
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX-Plattform muss innerhalb des Timeouts starten");
}
/** Plattform bleibt für nachfolgende Tests am Leben. */
@AfterAll
static void tearDownJavaFxPlatform() {
// Absichtlich kein Platform.exit() damit andere Smoke-Tests weiterhin die Plattform nutzen können.
}
// =========================================================================
// Versionsanzeige
// =========================================================================
/**
* Prüft, dass die Versionsanzeige das korrekte Präfix und die übergebene Version enthält.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void versionLabel_zeigtVersionMitPraefix() throws Exception {
AtomicReference<String> versionText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("3.0.42");
versionText.set(bar.versionText());
});
assertEquals("V3.0.42", versionText.get(),
"Die Versionsanzeige muss das Präfix 'V' gefolgt von der Versionsnummer enthalten");
}
/**
* Prüft, dass ein {@code null}-Wert für die Version als {@code "dev"} angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void versionLabel_mitNullFaellzurueckAufDev() throws Exception {
AtomicReference<String> versionText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar(null);
versionText.set(bar.versionText());
});
assertEquals("Vdev", versionText.get(),
"Ein null-Wert muss als Fallback 'dev' angezeigt werden");
}
/**
* Prüft, dass ein leerer String für die Version als {@code "dev"} angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void versionLabel_mitLeeremStringFaellzurueckAufDev() throws Exception {
AtomicReference<String> versionText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar(" ");
versionText.set(bar.versionText());
});
assertEquals("Vdev", versionText.get(),
"Ein leerer String muss als Fallback 'dev' angezeigt werden");
}
// =========================================================================
// Standardzustand ohne geladene Konfiguration
// =========================================================================
/**
* Prüft, dass Mitte und Rechts den Text Kein Profil geladen" zeigen, wenn keine
* Konfiguration geladen ist.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void ohneKonfiguration_zeigtKeinProfilGeladen() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
AtomicReference<String> configPathText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
providerText.set(bar.providerText());
configPathText.set(bar.configPathText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Provider-Text erscheinen");
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
}
/**
* Prüft, dass {@link GuiStatusBar#clearConfiguration()} Mitte und Rechts zurücksetzt.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void clearConfiguration_setztMitteUndRechtsZurueck() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
AtomicReference<String> configPathText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
// Zustand mit Konfiguration setzen, dann löschen
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
bar.applyEditorState(state);
bar.clearConfiguration();
providerText.set(bar.providerText());
configPathText.set(bar.configPathText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Nach clearConfiguration() muss 'Kein Profil geladen' als Provider-Text erscheinen");
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
"Nach clearConfiguration() muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
}
// =========================================================================
// Zustand nach Laden einer Konfiguration
// =========================================================================
/**
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Provider-Text
* mit Modell angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitClaudeUndModell_zeigtKorrektesFormat() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
bar.applyEditorState(state);
providerText.set(bar.providerText());
});
assertEquals("Provider: Claude · claude-opus-4-7", providerText.get(),
"Der Provider-Text muss das Format 'Provider: <Name> · <Modell>' haben");
}
/**
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Konfigurationspfad
* angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitKonfigurationspfad_zeigtKonfiguationspfad() throws Exception {
AtomicReference<String> configPathText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
bar.applyEditorState(state);
configPathText.set(bar.configPathText());
});
assertTrue(configPathText.get().contains("application.properties"),
"Der Konfigurationspfad muss den Dateinamen enthalten");
}
/**
* Prüft, dass ein OpenAI-kompatibler Provider korrekt angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitOpenAiUndModell_zeigtKorrektesFormat() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.OPENAI_COMPATIBLE, "gpt-4o");
bar.applyEditorState(state);
providerText.set(bar.providerText());
});
assertEquals("Provider: OpenAI-kompatibel · gpt-4o", providerText.get(),
"Der Provider-Text muss für OpenAI-kompatibel den deutschen Anzeigenamen verwenden");
}
/**
* Prüft, dass beim Übergeben eines {@code null}-Zustands kein Absturz erfolgt und der
* Text Kein Profil geladen" erscheint.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitNull_keinAbsturz() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
bar.applyEditorState(null);
providerText.set(bar.providerText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Ein null-Zustand darf keinen Absturz verursachen");
}
/**
* Prüft, dass ohne geladenen Dateisnapshot Kein Profil geladen" angezeigt wird,
* auch wenn Konfigurationswerte vorhanden sind.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_ohneSnapshot_zeigtKeinProfilGeladen() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
// Standard-Template hat keinen Snapshot
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
bar.applyEditorState(state);
providerText.set(bar.providerText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Ohne geladenen Dateisnapshot muss 'Kein Profil geladen' erscheinen");
}
/**
* Prüft, dass der Wurzelknoten der Statuszeile nicht null ist.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void root_istNichtNull() throws Exception {
AtomicBoolean rootNotNull = new AtomicBoolean(false);
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
rootNotNull.set(bar.root() != null);
});
assertTrue(rootNotNull.get(), "Der Wurzelknoten der Statuszeile darf nicht null sein");
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
/**
* Führt eine Aktion synchron auf dem JavaFX Application Thread aus und wartet auf Abschluss.
*
* @param action die auszuführende Aktion
* @throws Exception falls die Aktion einen Fehler wirft oder das Timeout überschritten wird
*/
private static void runOnFxThread(Runnable action) throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try {
action.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX-Thread-Task muss innerhalb des Timeouts abgeschlossen werden");
if (error.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", error.get());
}
}
/**
* Erstellt einen Editor-Zustand mit geladenem Dateisnapshot für den angegebenen
* Konfigurationspfad, Provider und Modell.
*
* @param configPath relativer Konfigurationsdateipfad
* @param family aktive Provider-Familie
* @param model Modellbezeichner
* @return ein Editor-Zustand mit Snapshot
*/
private static GuiConfigurationEditorState buildStateWithConfiguration(
String configPath, AiProviderFamily family, String model) {
GuiConfigurationEditorState template = GuiConfigurationTemplateFactory.createStandardTemplate();
// Provider und Modell setzen
GuiProviderConfigurationState providerState = new GuiProviderConfigurationState(
"https://api.example.com", model, "30",
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState.unresolved());
GuiConfigurationValues values = template.values()
.withActiveProviderFamily(family.getIdentifier())
.withProviderConfiguration(family, providerState);
// Snapshot anlegen
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
Path.of(configPath), new Properties());
return new GuiConfigurationEditorState(
Optional.of(snapshot), values, values, Optional.empty());
}
}
@@ -39,7 +39,7 @@ import javafx.scene.control.Button;
* {@code technical-tests-button}.</li>
* <li>Triggering the coordinator synchronously populates {@code pendingMessages}
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li>
* <li>A second trigger appends a fresh batch of test entries (accumulation semantics).</li>
* <li>A second trigger replaces the previous batch of test entries.</li>
* <li>The post-result callback is invoked after the result is applied.</li>
* </ul>
* <p>
@@ -138,12 +138,12 @@ class GuiTechnicalTestCoordinatorSmokeTest {
/**
* Smoke test: after one trigger, the number of entries tagged SOURCE_TAG equals
* 11 (one per checkpoint) plus 1 summary entry = 12.
* 12 (one per checkpoint) plus 1 summary entry = 13.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void trigger_producesElevenCheckpointEntriesPlusSummary() throws Exception {
void trigger_producesTwelveCheckpointEntriesPlusSummary() throws Exception {
runOnFx(() -> {
List<GuiMessageEntry> messages = new ArrayList<>();
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
@@ -155,9 +155,9 @@ class GuiTechnicalTestCoordinatorSmokeTest {
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
.count();
// 11 checkpoint entries + 1 summary entry = 12
assertEquals(12, taggedCount,
"Expected 11 checkpoint entries + 1 summary entry = 12 tagged messages");
// 12 checkpoint entries + 1 summary entry = 13
assertEquals(13, taggedCount,
"Expected 12 checkpoint entries + 1 summary entry = 13 tagged messages");
});
}
@@ -256,12 +256,14 @@ class GuiTechnicalTestCoordinatorSmokeTest {
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
new EditorConfigurationValidator(),
noOpPathCheckPort(),
noOpProviderService());
noOpProviderService(),
() -> java.util.Optional.empty());
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
orchestrator,
currentInput::get, // always reads the current reference
() -> "",
() -> "",
messages,
report -> { });
@@ -365,7 +367,8 @@ class GuiTechnicalTestCoordinatorSmokeTest {
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
new EditorConfigurationValidator(),
noOpPathCheckPort(),
noOpProviderService());
noOpProviderService(),
() -> java.util.Optional.empty());
EditorValidationInput blankInput = new EditorValidationInput(
"claude",
@@ -380,6 +383,7 @@ class GuiTechnicalTestCoordinatorSmokeTest {
orchestrator,
() -> blankInput,
() -> "",
() -> "",
messages,
postResultCallback);
@@ -0,0 +1,205 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Unit-Tests für {@link GuiTooltipTexts}.
* <p>
* Prüft, dass alle öffentlichen Tooltip-Konstanten vorhanden sind, nicht leer sind
* und den exakten Texten gemäß Spezifikation entsprechen.
*/
class GuiTooltipTextsTest {
// -------------------------------------------------------------------------
// Vollständigkeit und Nicht-Leerheit aller Konstanten
// -------------------------------------------------------------------------
@Test
void alleKonstantenSindNichtNullUndNichtLeer() {
List<String> fehler = new ArrayList<>();
for (Field field : GuiTooltipTexts.class.getDeclaredFields()) {
if (!Modifier.isPublic(field.getModifiers())
|| !Modifier.isStatic(field.getModifiers())
|| !Modifier.isFinal(field.getModifiers())) {
continue;
}
try {
Object value = field.get(null);
if (value == null) {
fehler.add(field.getName() + " ist null");
} else if (value instanceof String s && s.isBlank()) {
fehler.add(field.getName() + " ist leer");
}
} catch (IllegalAccessException e) {
fehler.add(field.getName() + " nicht zugreifbar: " + e.getMessage());
}
}
if (!fehler.isEmpty()) {
org.junit.jupiter.api.Assertions.fail(
"Fehlerhafte Tooltip-Konstanten: " + String.join(", ", fehler));
}
}
// -------------------------------------------------------------------------
// Toolbar-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void toolbar_neu_entsprichtSpezifikation() {
assertNotNull(GuiTooltipTexts.TOOLBAR_NEU);
assertFalse(GuiTooltipTexts.TOOLBAR_NEU.isBlank());
org.junit.jupiter.api.Assertions.assertEquals(
"Neue Konfiguration erstellen.",
GuiTooltipTexts.TOOLBAR_NEU);
}
@Test
void toolbar_oeffnen_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Bestehende Konfigurationsdatei (.properties) öffnen.",
GuiTooltipTexts.TOOLBAR_OEFFNEN);
}
@Test
void toolbar_speichern_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Aktuelle Konfiguration speichern.",
GuiTooltipTexts.TOOLBAR_SPEICHERN);
}
@Test
void toolbar_speichernUnter_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Konfiguration unter neuem Dateipfad speichern.",
GuiTooltipTexts.TOOLBAR_SPEICHERN_UNTER);
}
@Test
void toolbar_validieren_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.",
GuiTooltipTexts.TOOLBAR_VALIDIEREN);
}
@Test
void toolbar_technischeTests_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.",
GuiTooltipTexts.TOOLBAR_TECHNISCHE_TESTS);
}
// -------------------------------------------------------------------------
// Pfade-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void pfade_quellordner_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.",
GuiTooltipTexts.PFADE_QUELLORDNER);
}
@Test
void pfade_zielordner_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Ordner für die umbenannten Kopien.",
GuiTooltipTexts.PFADE_ZIELORDNER);
}
@Test
void pfade_sqlite_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.",
GuiTooltipTexts.PFADE_SQLITE);
}
@Test
void pfade_prompt_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Externe Textdatei mit den KI-Anweisungen.",
GuiTooltipTexts.PFADE_PROMPT);
}
// -------------------------------------------------------------------------
// Provider-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void provider_combobox_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Der KI-Dienst, der die Dateinamen generiert.",
GuiTooltipTexts.PROVIDER_COMBOBOX);
}
@Test
void provider_modell_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Das konkrete Sprachmodell des gewählten Providers.",
GuiTooltipTexts.PROVIDER_MODELL);
}
// -------------------------------------------------------------------------
// Verarbeitungslimits-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void limits_maxTextCharacters_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.",
GuiTooltipTexts.LIMITS_MAX_TEXT_CHARACTERS);
}
@Test
void limits_maxPages_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Maximale Seitenzahl, die aus einem PDF gelesen wird.",
GuiTooltipTexts.LIMITS_MAX_PAGES);
}
@Test
void limits_maxTitleLength_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10120.",
GuiTooltipTexts.LIMITS_MAX_TITLE_LENGTH);
}
// -------------------------------------------------------------------------
// Verarbeitungslauf-Tab-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void dateiname_uebernehmen_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.",
GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
}
@Test
void dateiname_zuruecksetzen_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.",
GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
}
// -------------------------------------------------------------------------
// Nicht instanziierbar
// -------------------------------------------------------------------------
@Test
void konstruktorWirftException() throws Exception {
Constructor<GuiTooltipTexts> ctor = GuiTooltipTexts.class.getDeclaredConstructor();
ctor.setAccessible(true);
assertThrows(java.lang.reflect.InvocationTargetException.class, ctor::newInstance,
"Der private Konstruktor muss UnsupportedOperationException werfen");
}
}
@@ -806,7 +806,8 @@ class GuiUnsavedChangesGuardSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -851,7 +852,8 @@ class GuiUnsavedChangesGuardSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -323,7 +323,8 @@ class GuiValidateActionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
noOpApiKeyResolutionPort())),
noOpApiKeyResolutionPort()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -390,7 +391,8 @@ class GuiValidateActionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
noOpApiKeyResolutionPort())),
noOpApiKeyResolutionPort()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -0,0 +1,233 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Unit-Tests für {@link BatchRunSummaryBanner}.
* <p>
* Geprüft werden die Aggregationslogik und die Textgenerierung unabhängig von JavaFX.
* Die GUI-Integrationsmethoden ({@code clear()}, {@code update()}, {@code getNode()})
* erfordern eine JavaFX-Runtime und werden durch Smoke-Tests abgedeckt.
*/
class BatchRunSummaryBannerTest {
// -------------------------------------------------------------------------
// Hilfsmethoden für Testdaten
// -------------------------------------------------------------------------
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
return new GuiBatchRunResultRow(
"test.pdf",
new DocumentFingerprint("a".repeat(64)),
status,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
false,
Optional.empty());
}
private static GuiBatchRunResultRow resetPendingRow() {
GuiBatchRunResultRow base = row(DocumentCompletionStatus.SUCCESS);
return GuiBatchRunResultRow.resetMarker(base);
}
// -------------------------------------------------------------------------
// aggregateCounts
// -------------------------------------------------------------------------
@Test
void aggregateCounts_leereListe_alleZaehlerNull() {
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(Collections.emptyList());
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SUCCESS, 0));
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_RETRYABLE, 0));
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_PERMANENT, 0));
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0));
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0));
}
@Test
void aggregateCounts_nurErfolgreiche_zaehltNurSuccess() {
List<GuiBatchRunResultRow> rows = List.of(
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.SUCCESS));
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(rows);
assertEquals(3, counts.get(DocumentCompletionStatus.SUCCESS));
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
}
@Test
void aggregateCounts_gemischterLauf_alleKategorienKorrekt() {
List<GuiBatchRunResultRow> rows = List.of(
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.FAILED_RETRYABLE),
row(DocumentCompletionStatus.FAILED_PERMANENT),
row(DocumentCompletionStatus.FAILED_PERMANENT),
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(rows);
assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS));
assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
assertEquals(2, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
assertEquals(3, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
assertEquals(1, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
}
@Test
void aggregateCounts_resetPendingZeilenWerdenNichtGezaehlt() {
// Reset-Pending-Zeilen haben noch keinen abgeschlossenen Status und
// dürfen nicht ins Summary einfließen
List<GuiBatchRunResultRow> rows = List.of(
row(DocumentCompletionStatus.SUCCESS),
resetPendingRow(),
resetPendingRow());
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(rows);
assertEquals(1, counts.get(DocumentCompletionStatus.SUCCESS));
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
}
// -------------------------------------------------------------------------
// buildBannerText
// -------------------------------------------------------------------------
@Test
void buildBannerText_alleZaehlerNull_leerString() {
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 0,
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
DocumentCompletionStatus.FAILED_PERMANENT, 0,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
String text = BatchRunSummaryBanner.buildBannerText(counts);
assertTrue(text.isEmpty(), "Leere Zähler ergeben leeren Text: '" + text + "'");
}
@Test
void buildBannerText_nurErfolgreiche_nurSuccessSegment() {
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 17,
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
DocumentCompletionStatus.FAILED_PERMANENT, 0,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
String text = BatchRunSummaryBanner.buildBannerText(counts);
assertTrue(text.contains("17"), "Anzahl 17 muss im Text erscheinen: " + text);
assertTrue(text.contains("erfolgreich"), "Kategorie 'erfolgreich' muss erscheinen: " + text);
assertTrue(text.contains(""), "Icon ✓ muss erscheinen: " + text);
assertFalse(text.contains(""), "Kein ↻ wenn FAILED_RETRYABLE = 0: " + text);
assertFalse(text.contains("×"), "Kein × wenn FAILED_PERMANENT = 0: " + text);
assertFalse(text.contains(""), "Kein ≡ wenn SKIPPED_ALREADY_PROCESSED = 0: " + text);
assertFalse(text.contains(""), "Kein ⊘ wenn SKIPPED_FINAL_FAILURE = 0: " + text);
}
@Test
void buildBannerText_vollerLauf_alleSegmenteEnthalten() {
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 14,
DocumentCompletionStatus.FAILED_RETRYABLE, 1,
DocumentCompletionStatus.FAILED_PERMANENT, 2,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 3,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 1);
String text = BatchRunSummaryBanner.buildBannerText(counts);
// Jedes Segment enthält Icon + Anzahl + Kategorie
assertTrue(text.contains("✓ 14 erfolgreich"), "SUCCESS-Segment: " + text);
assertTrue(text.contains("↻ 1 wird wiederholt"), "FAILED_RETRYABLE-Segment: " + text);
assertTrue(text.contains("× 2 fehlgeschlagen"), "FAILED_PERMANENT-Segment: " + text);
assertTrue(text.contains("≡ 3 übersprungen"), "SKIPPED_ALREADY_PROCESSED-Segment: " + text);
assertTrue(text.contains("⊘ 1 endgültig übersprungen"), "SKIPPED_FINAL_FAILURE-Segment: " + text);
}
@Test
void buildBannerText_nurSkippedFinalFailure_erscheintImBanner() {
// Sicherstellung: erscheint auch wenn > 0, obwohl es die seltenste Kategorie ist
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 0,
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
DocumentCompletionStatus.FAILED_PERMANENT, 0,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 2);
String text = BatchRunSummaryBanner.buildBannerText(counts);
assertTrue(text.contains(""), "Icon ⊘ muss erscheinen: " + text);
assertTrue(text.contains("2"), "Anzahl 2 muss erscheinen: " + text);
assertTrue(text.contains("endgültig übersprungen"), "Kategorie muss erscheinen: " + text);
}
@Test
void buildBannerText_nurKategorienMitAnzahlGroesserNull_erscheinen() {
// Nur SUCCESS=5 ist gesetzt; alle anderen 0 kein anderes Segment
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 5,
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
DocumentCompletionStatus.FAILED_PERMANENT, 0,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
String text = BatchRunSummaryBanner.buildBannerText(counts);
// Kein Trennzeichen (·) darf erscheinen, wenn nur ein Segment vorhanden ist
assertFalse(text.contains("·"), "Kein Trenner bei einzelnem Segment: " + text);
assertTrue(text.contains("✓ 5 erfolgreich"), "Nur SUCCESS-Segment: " + text);
}
@Test
void aggregateCounts_kombinationMitResetPending_nurEchtAbgeschlosseneGezaehlt() {
// 2 SUCCESS + 1 FAILED_PERMANENT + 1 resetPending(SUCCESS) nur 2+1 gezählt
List<GuiBatchRunResultRow> rows = List.of(
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.FAILED_PERMANENT),
resetPendingRow());
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(rows);
assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS));
assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
// Summe aller gezählten Einträge = 3, nicht 4
int total = counts.values().stream().mapToInt(Integer::intValue).sum();
assertEquals(3, total, "Reset-Pending darf nicht mitgezählt werden");
}
}
@@ -119,9 +119,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
void startReset_invokesResetPortAndDispatchesResult() {
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
@Override public void onRunStarted(RunId runId, int totalCandidates) {
// intentionally empty
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
captured.set(result);
}
@@ -170,9 +176,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
void startReset_portThrowsException_mapsToAllFailures() {
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
@Override public void onRunStarted(RunId runId, int totalCandidates) {
// intentionally empty
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
captured.set(result);
}
@@ -198,9 +210,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
void listenerDefaultOnResetCompleted_doesNotThrow() {
// Verify the default implementation is safe to call.
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
@Override public void onRunStarted(RunId runId, int totalCandidates) {
// intentionally empty
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
};
listener.onResetCompleted(new ResetDocumentStatusResult(0, Set.of(), Map.of()));
}
@@ -223,9 +241,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
private static GuiBatchRunCoordinator.Listener noOpListener() {
return new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
@Override public void onRunStarted(RunId runId, int totalCandidates) {
// intentionally empty
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
};
}
@@ -247,8 +247,12 @@ class GuiBatchRunCoordinatorTest {
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
launcher, syncThreadFactory(), syncDispatcher(),
new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunStarted(RunId runId, int totalCandidates) {
// intentionally empty
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
captured.set(outcome);
}
@@ -270,8 +274,12 @@ class GuiBatchRunCoordinatorTest {
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
launcher, syncThreadFactory(), syncDispatcher(),
new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunStarted(RunId runId, int totalCandidates) {
// intentionally empty
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
captured.set(outcome);
}
@@ -322,9 +330,15 @@ class GuiBatchRunCoordinatorTest {
private static GuiBatchRunCoordinator.Listener noOpListener() {
return new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
@Override public void onRunStarted(RunId runId, int totalCandidates) {
// intentionally empty
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
};
}
@@ -188,6 +188,50 @@ class GuiBatchRunResultRowTest {
}
}
// -------------------------------------------------------------------------
// statusTooltip
// -------------------------------------------------------------------------
@Test
void statusTooltip_success_isNonBlank() {
assertFalse(row(DocumentCompletionStatus.SUCCESS).statusTooltip().isBlank());
}
@Test
void statusTooltip_failedRetryable_isNonBlank() {
assertFalse(row(DocumentCompletionStatus.FAILED_RETRYABLE).statusTooltip().isBlank());
}
@Test
void statusTooltip_failedPermanent_isNonBlank() {
assertFalse(row(DocumentCompletionStatus.FAILED_PERMANENT).statusTooltip().isBlank());
}
@Test
void statusTooltip_failedRetryable_and_failedPermanent_areDifferent() {
String retryable = row(DocumentCompletionStatus.FAILED_RETRYABLE).statusTooltip();
String permanent = row(DocumentCompletionStatus.FAILED_PERMANENT).statusTooltip();
assertFalse(retryable.equals(permanent),
"FAILED_RETRYABLE und FAILED_PERMANENT müssen unterschiedliche Tooltips haben");
}
@Test
void statusTooltip_allStatuses_delegatesToProcessingStatusPresentation() {
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
String rowTooltip = row(status).statusTooltip();
String expectedTooltip = ProcessingStatusPresentation.tooltipFor(status);
assertEquals(expectedTooltip, rowTooltip,
"statusTooltip() soll Wert von ProcessingStatusPresentation liefern für " + status);
}
}
@Test
void statusTooltip_resetPending_returnsResetLabel() {
GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(
row(DocumentCompletionStatus.SUCCESS));
assertEquals(GuiBatchRunResultRow.RESET_PENDING_LABEL, marker.statusTooltip());
}
// -------------------------------------------------------------------------
// aiFailureMessage
// -------------------------------------------------------------------------
@@ -0,0 +1,149 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Path;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javafx.application.Platform;
/**
* Headless (Monocle) Tests, die echte PDF-Dateien rendern, damit die
* Worker-Thread-Pfade {@code loadAndRenderFirstPageOnWorker} und
* {@code renderPageOnWorker} tatsächlich ausgeführt werden.
*/
class PdfPreviewPaneRenderingTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final long WORKER_TIMEOUT_SECONDS = 15;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void startPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
if (PLATFORM_STARTED.compareAndSet(false, true)) {
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(latch::countDown);
} catch (IllegalStateException alreadyStarted) {
latch.countDown();
}
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
}
}
@Test
void loadSource_realSinglePagePdf_pageLabelShowsRenderedPage(@TempDir Path tempDir) throws Exception {
Path pdfFile = tempDir.resolve("single-page.pdf");
createPdfWithPages(pdfFile, 1);
AtomicReference<PdfPreviewPane> paneRef = new AtomicReference<>();
CountDownLatch firstPageRendered = new CountDownLatch(1);
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
paneRef.set(pane);
pane.pageLabel().textProperty().addListener((obs, old, newText) -> {
if (newText != null && newText.contains("Seite 1 / 1")) {
firstPageRendered.countDown();
}
});
pane.loadSource(pdfFile);
});
assertTrue(firstPageRendered.await(WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Erste Seite eines einseitigen PDFs muss innerhalb der Worker-Timeout-Frist gerendert werden");
runOnFx(() -> paneRef.get().shutdown());
}
@Test
void navigateToNextPage_multiPagePdf_rendersSecondPage(@TempDir Path tempDir) throws Exception {
Path pdfFile = tempDir.resolve("multi-page.pdf");
createPdfWithPages(pdfFile, 3);
AtomicReference<PdfPreviewPane> paneRef = new AtomicReference<>();
CountDownLatch firstPageRendered = new CountDownLatch(1);
CountDownLatch secondPageRendered = new CountDownLatch(1);
AtomicBoolean firstSeen = new AtomicBoolean(false);
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
paneRef.set(pane);
pane.pageLabel().textProperty().addListener((obs, old, newText) -> {
if (newText == null) {
return;
}
if (newText.contains("Seite 1 / 3") && firstSeen.compareAndSet(false, true)) {
firstPageRendered.countDown();
} else if (newText.contains("Seite 2 / 3")) {
secondPageRendered.countDown();
}
});
pane.loadSource(pdfFile);
});
assertTrue(firstPageRendered.await(WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Erste Seite muss innerhalb der Worker-Timeout-Frist gerendert werden");
// Auf zweite Seite navigieren triggert renderPageOnWorker
runOnFx(() -> paneRef.get().nextButton().fire());
assertTrue(secondPageRendered.await(WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Zweite Seite muss nach Klick auf Weiter gerendert werden");
runOnFx(() -> paneRef.get().shutdown());
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static void createPdfWithPages(Path outputPath, int pages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 1; i <= pages; i++) {
PDPage page = new PDPage();
doc.addPage(page);
try (PDPageContentStream stream = new PDPageContentStream(doc, page)) {
stream.beginText();
stream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
stream.newLineAtOffset(50, 700);
stream.showText("Testseite " + i);
stream.endText();
}
}
doc.save(outputPath.toFile());
}
}
private void runOnFx(Runnable action) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try {
action.run();
} catch (Throwable t) {
error.set(t);
} finally {
done.countDown();
}
});
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "FX-Thread Timeout");
if (error.get() != null) {
throw new AssertionError(error.get());
}
}
}
@@ -0,0 +1,271 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation.StatusVisuals;
/**
* Unit-Tests für {@link ProcessingStatusPresentation}.
* <p>
* Prüft, dass alle {@link DocumentCompletionStatus}-Werte korrekte Icons, Farben,
* Tooltip-Texte und Summary-Kategorielabels liefern und dass keine zwei Status
* dasselbe Icon teilen.
*/
class ProcessingStatusPresentationTest {
// -------------------------------------------------------------------------
// iconFor
// -------------------------------------------------------------------------
@Test
void iconFor_success_isCheckMark() {
assertEquals(ProcessingStatusPresentation.ICON_SUCCESS,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SUCCESS));
}
@Test
void iconFor_failedRetryable_isClockwiseArrow() {
assertEquals(ProcessingStatusPresentation.ICON_FAILED_RETRYABLE,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE));
}
@Test
void iconFor_failedPermanent_isMultiplicationSign() {
assertEquals(ProcessingStatusPresentation.ICON_FAILED_PERMANENT,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT));
}
@Test
void iconFor_skippedAlreadyProcessed_isIdenticalTo() {
assertEquals(ProcessingStatusPresentation.ICON_SKIPPED_ALREADY_PROCESSED,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
}
@Test
void iconFor_skippedFinalFailure_isCircledDivisionSlash() {
assertEquals(ProcessingStatusPresentation.ICON_SKIPPED_FINAL_FAILURE,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
}
@Test
void iconFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.iconFor(null));
}
@Test
void icons_areAllDistinct() {
Set<String> icons = new HashSet<>();
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
icons.add(ProcessingStatusPresentation.iconFor(status));
}
assertEquals(DocumentCompletionStatus.values().length, icons.size(),
"Jeder Status muss ein eindeutiges Icon haben");
}
// -------------------------------------------------------------------------
// cssColorFor
// -------------------------------------------------------------------------
@ParameterizedTest
@EnumSource(DocumentCompletionStatus.class)
void cssColorFor_allStatuses_returnsNonBlankHexColor(DocumentCompletionStatus status) {
String color = ProcessingStatusPresentation.cssColorFor(status);
assertAll(
() -> assertNotNull(color, "Farbe darf nicht null sein für " + status),
() -> assertFalse(color.isBlank(), "Farbe darf nicht leer sein für " + status),
() -> assertFalse(color.isEmpty(), "Farbe darf nicht leer sein für " + status)
);
// Farbe muss im CSS-Hex-Format beginnen (#)
assertFalse(color.isBlank());
assertEquals('#', color.charAt(0), "CSS-Farbe muss mit # beginnen für " + status);
}
@Test
void cssColorFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.cssColorFor(null));
}
@Test
void failedRetryable_and_failedPermanent_haveDifferentColors() {
String orangeColor = ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_RETRYABLE);
String redColor = ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_PERMANENT);
assertFalse(orangeColor.equals(redColor),
"FAILED_RETRYABLE (Orange) und FAILED_PERMANENT (Rot) müssen unterschiedliche Farben haben");
}
// -------------------------------------------------------------------------
// tooltipFor
// -------------------------------------------------------------------------
@ParameterizedTest
@EnumSource(DocumentCompletionStatus.class)
void tooltipFor_allStatuses_returnsNonBlankText(DocumentCompletionStatus status) {
String tooltip = ProcessingStatusPresentation.tooltipFor(status);
assertNotNull(tooltip, "Tooltip darf nicht null sein für " + status);
assertFalse(tooltip.isBlank(), "Tooltip darf nicht leer sein für " + status);
}
@Test
void tooltipFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.tooltipFor(null));
}
@Test
void tooltipFor_failedRetryable_containsWiederholung() {
String tooltip = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_RETRYABLE);
assertFalse(tooltip.isBlank());
// Tooltip muss die Retry-Semantik kommunizieren
assertFalse(tooltip.equals(ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT)),
"FAILED_RETRYABLE und FAILED_PERMANENT müssen unterschiedliche Tooltips haben");
}
@Test
void tooltipFor_failedPermanent_containsKeinWeitererVersuch() {
String tooltip = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT);
// Tooltip für FAILED_PERMANENT muss kommunizieren, dass kein weiterer automatischer Versuch folgt
assertFalse(tooltip.isBlank());
}
@Test
void tooltips_areAllDistinct() {
Set<String> tooltips = new HashSet<>();
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
tooltips.add(ProcessingStatusPresentation.tooltipFor(status));
}
assertEquals(DocumentCompletionStatus.values().length, tooltips.size(),
"Jeder Status muss einen eindeutigen Tooltip haben");
}
// -------------------------------------------------------------------------
// summaryCategoryFor
// -------------------------------------------------------------------------
@ParameterizedTest
@EnumSource(DocumentCompletionStatus.class)
void summaryCategoryFor_allStatuses_returnsNonBlankLabel(DocumentCompletionStatus status) {
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
assertNotNull(category, "Summary-Kategorie darf nicht null sein für " + status);
assertFalse(category.isBlank(), "Summary-Kategorie darf nicht leer sein für " + status);
}
@Test
void summaryCategoryFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.summaryCategoryFor(null));
}
// -------------------------------------------------------------------------
// visualsFor (gebündelt)
// -------------------------------------------------------------------------
@ParameterizedTest
@EnumSource(DocumentCompletionStatus.class)
void visualsFor_allStatuses_returnsConsistentRecord(DocumentCompletionStatus status) {
StatusVisuals visuals = ProcessingStatusPresentation.visualsFor(status);
assertAll(
() -> assertNotNull(visuals, "StatusVisuals darf nicht null sein für " + status),
() -> assertEquals(ProcessingStatusPresentation.iconFor(status), visuals.icon()),
() -> assertEquals(ProcessingStatusPresentation.cssColorFor(status), visuals.cssColor()),
() -> assertEquals(ProcessingStatusPresentation.tooltipFor(status), visuals.tooltipText()),
() -> assertEquals(ProcessingStatusPresentation.summaryCategoryFor(status),
visuals.summaryCategoryLabel())
);
}
@Test
void visualsFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.visualsFor(null));
}
// -------------------------------------------------------------------------
// Spezifische Status-Mapping-Werte (gemäß Spezifikation)
// -------------------------------------------------------------------------
@Test
void success_mapping_correctValues() {
assertAll(
() -> assertEquals("", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SUCCESS)),
() -> assertEquals("#2e7d32", ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.SUCCESS)),
() -> assertEquals("erfolgreich",
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.SUCCESS))
);
}
@Test
void failedRetryable_mapping_correctValues() {
assertAll(
() -> assertEquals("", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE)),
() -> assertEquals("#d98200",
ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_RETRYABLE)),
() -> assertEquals("wird wiederholt",
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.FAILED_RETRYABLE))
);
}
@Test
void failedPermanent_mapping_correctValues() {
assertAll(
() -> assertEquals("×", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT)),
() -> assertEquals("#c62828",
ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_PERMANENT)),
() -> assertEquals("fehlgeschlagen",
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.FAILED_PERMANENT))
);
}
@Test
void skippedAlreadyProcessed_mapping_correctValues() {
assertAll(
() -> assertEquals("",
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED)),
() -> assertEquals("übersprungen",
ProcessingStatusPresentation.summaryCategoryFor(
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED))
);
}
@Test
void skippedFinalFailure_mapping_correctValues() {
assertAll(
() -> assertEquals("",
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE)),
() -> assertEquals("endgültig übersprungen",
ProcessingStatusPresentation.summaryCategoryFor(
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE))
);
}
// -------------------------------------------------------------------------
// Farbe ist NICHT einziges Unterscheidungsmerkmal
// -------------------------------------------------------------------------
@Test
void failedRetryable_and_failedPermanent_distinctByIconAndTooltip() {
String iconRetryable = ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE);
String iconPermanent = ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT);
String tooltipRetryable = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_RETRYABLE);
String tooltipPermanent = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT);
assertAll(
() -> assertFalse(iconRetryable.equals(iconPermanent),
"Icons müssen sich unterscheiden"),
() -> assertFalse(tooltipRetryable.equals(tooltipPermanent),
"Tooltips müssen sich unterscheiden")
);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>pdf-umbenenner-adapter-in-scheduler</artifactId>
<packaging>jar</packaging>
<!--
Inbound-Adapter: autonomer Scheduler-Betrieb.
Abhängigkeitsrichtung (hexagonale Architektur):
adapter-in-scheduler → application → domain
KEIN Rückwärtsverweis auf pdf-umbenenner-bootstrap: das Bootstrap-Modul
verdrahtet den Scheduler und hängt selbst von diesem Modul ab eine
umgekehrte Abhängigkeit würde einen Zyklus erzeugen.
ApplicationRunContext (package-private im Bootstrap-Modul) ist von hier
aus nicht direkt erreichbar. Die Schnittstelle zwischen Bootstrap und
diesem Modul wird über das BatchRunTrigger-Functional-Interface realisiert,
das im Bootstrap-Modul liegt und beim Start injiziert wird.
JavaFX ist bewusst ausgeschlossen: dieser Adapter läuft ohne Benutzeroberfläche.
maven-shade-plugin ist bewusst ausgeschlossen: das ausführbare JAR wird
ausschließlich im Bootstrap-Modul per Shade-Plugin erzeugt.
-->
<dependencies>
<!-- Interner Abhängigkeiten: Inbound-Adapter bezieht Ports und Use-Cases
ausschließlich aus der Application-Schicht -->
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-application</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
</dependency>
<!-- Test-Abhängigkeiten -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!--
flatten-maven-plugin: wird vom Parent geerbt und löst ${revision} in
installierten POMs auf. Keine eigene Konfiguration erforderlich
der Eintrag ist nur zur bewussten Dokumentation dieser Erbschaftsentscheidung
vorhanden.
-->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>jacoco-check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<executions>
<execution>
<id>pitest</id>
<phase>verify</phase>
<goals>
<goal>mutationCoverage</goal>
</goals>
<configuration>
<!--
PIT wird für diesen Adapter explizit deaktiviert. Der Parent
setzt skip=true als Standardwert; hier wird das bewusst
wiederholt dokumentiert. Mutations-Tests werden erst
aktiviert, wenn echte Produktionslogik vorliegt.
-->
<skip>true</skip>
<coverageThreshold>0</coverageThreshold>
<mutationThreshold>0</mutationThreshold>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,361 @@
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsPort;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsWriteException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import java.util.regex.Pattern;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Implementiert {@link ConfigurationFileLockPort} und {@link SchedulerSettingsPort}
* auf Basis eines gemeinsam genutzten {@link FileChannel}.
* <p>
* Der exklusive OS-Lock auf die {@code .properties}-Datei wird über
* {@link FileChannel#tryLock()} mit einer Deadline-Wiederholschleife erworben.
* Solange der Lock gehalten wird, erfolgen Schreibvorgänge direkt über
* den bereits offenen Kanal (Truncate Position(0) Write Force).
* Ohne aktiven Lock werden Schreibvorgänge über eine temporäre Datei
* und {@link Files#move} mit {@code ATOMIC_MOVE} und {@code REPLACE_EXISTING}
* durchgeführt.
* <p>
* Beide Ports teilen den internen {@link FileChannel}, damit
* Settings-Schreibvorgänge auch während eines aktiven OS-Locks korrekt
* in die Konfigurationsdatei durchgeschrieben werden können.
* <p>
* Instanzen dieser Klasse sind <em>nicht</em> Thread-sicher. Der Aufrufer
* ist für die Serialisierung konkurrierender Zugriffe verantwortlich.
*/
public class FileChannelConfigurationAccessAdapter
implements ConfigurationFileLockPort, SchedulerSettingsPort {
private static final Logger logger =
LogManager.getLogger(FileChannelConfigurationAccessAdapter.class);
private static final long ACQUIRE_TIMEOUT_MS = 3000L;
private static final long ACQUIRE_RETRY_INTERVAL_MS = 100L;
private static final String KEY_INTERVAL = "scheduler.interval.seconds";
private final Path configFile;
private FileChannel channel;
private FileLock fileLock;
/**
* Erstellt einen neuen Adapter für die angegebene Konfigurationsdatei.
*
* @param configFile Pfad zur {@code .properties}-Konfigurationsdatei;
* darf nicht {@code null} sein
*/
public FileChannelConfigurationAccessAdapter(Path configFile) {
this.configFile = Objects.requireNonNull(configFile, "configFile darf nicht null sein");
}
// -------------------------------------------------------------------------
// ConfigurationFileLockPort
// -------------------------------------------------------------------------
/**
* Erwirbt den exklusiven OS-Lock auf die Konfigurationsdatei.
* <p>
* Ist der Lock bereits durch diese Instanz gehalten, hat dieser Aufruf
* keine Wirkung (idempotent). Andernfalls wird der {@link FileChannel}
* mit {@link StandardOpenOption#READ} und {@link StandardOpenOption#WRITE}
* geöffnet und {@link FileChannel#tryLock()} in einer Schleife mit
* {@value ACQUIRE_RETRY_INTERVAL_MS}-ms-Pausen versucht. Schlägt der
* Erwerb innerhalb von {@value ACQUIRE_TIMEOUT_MS} ms fehl, werden
* Kanal und Lock geschlossen und eine {@link ConfigurationFileLockException}
* geworfen.
*
* @throws ConfigurationFileLockException wenn der Lock nicht innerhalb der
* Deadline erworben werden kann, ein I/O-Fehler auftritt oder der
* Thread unterbrochen wird
*/
@Override
public void acquireLock() throws ConfigurationFileLockException {
if (isLocked()) {
return;
}
long deadline = System.currentTimeMillis() + ACQUIRE_TIMEOUT_MS;
try {
channel = FileChannel.open(configFile,
StandardOpenOption.READ, StandardOpenOption.WRITE);
while (true) {
try {
FileLock lock = channel.tryLock();
if (lock != null) {
this.fileLock = lock;
logger.debug("OS-Lock auf Konfigurationsdatei erworben: {}", configFile);
return;
}
} catch (OverlappingFileLockException e) {
// Dieselbe JVM hält bereits einen Lock auf diesen Dateibereich;
// wird wie ein nicht verfügbarer Lock behandelt.
}
if (System.currentTimeMillis() >= deadline) {
closeChannelSilently();
throw new ConfigurationFileLockException(
"Konfigurationsdatei konnte nicht gesperrt werden: "
+ "Timeout nach " + ACQUIRE_TIMEOUT_MS + " ms. Datei: " + configFile);
}
Thread.sleep(ACQUIRE_RETRY_INTERVAL_MS);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
closeChannelSilently();
throw new ConfigurationFileLockException(
"Lock-Erwerb auf Konfigurationsdatei wurde unterbrochen.", e);
} catch (IOException e) {
closeChannelSilently();
throw new ConfigurationFileLockException(
"Konfigurationsdatei konnte nicht geöffnet oder gesperrt werden: "
+ configFile, e);
}
}
/**
* Gibt den exklusiven Lock frei und schließt den {@link FileChannel}.
* <p>
* Ist kein Lock aktiv, hat dieser Aufruf keine Wirkung (idempotent).
* Aufgetretene I/O-Fehler werden geloggt und still übergangen.
*/
@Override
public void releaseLock() {
if (fileLock != null) {
try {
fileLock.release();
logger.debug("OS-Lock auf Konfigurationsdatei freigegeben: {}", configFile);
} catch (IOException e) {
logger.warn("Fehler beim Freigeben des FileLock für {}.", configFile, e);
}
fileLock = null;
}
closeChannelSilently();
}
/**
* Prüft, ob der Lock aktuell von dieser Instanz gehalten wird.
*
* @return {@code true}, wenn der Lock aktiv und gültig ist
*/
@Override
public boolean isLocked() {
return fileLock != null && fileLock.isValid();
}
// -------------------------------------------------------------------------
// SchedulerSettingsPort
// -------------------------------------------------------------------------
/**
* Liest die aktuellen Scheduler-Einstellungen aus der Konfigurationsdatei.
* <p>
* Fehlt ein Key oder ist er leer, wird der jeweilige Standardwert aus
* {@link SchedulerSettings#defaults()} zurückgegeben. Ungültige Werte
* (z.B. nicht-numerisches Intervall) führen ebenfalls zu den Standardwerten,
* nicht zu einer Exception.
*
* @return aktuelle Scheduler-Einstellungen; nie {@code null}
*/
@Override
public SchedulerSettings loadSettings() {
Properties props = new Properties();
try {
String content = Files.readString(configFile, StandardCharsets.UTF_8);
props.load(new StringReader(content));
} catch (IOException e) {
logger.warn("Scheduler-Einstellungen konnten nicht geladen werden, "
+ "Standardwerte werden verwendet. Datei: {}", configFile, e);
return SchedulerSettings.defaults();
}
int intervalSeconds = parseInterval(props.getProperty(KEY_INTERVAL));
return new SchedulerSettings(intervalSeconds);
}
/**
* Schreibt den Wert von {@code scheduler.interval.seconds} in die
* Konfigurationsdatei.
* <p>
* Alle übrigen Inhalte der Datei bleiben unverändert. Existiert der Key
* noch nicht, wird er am Ende der Datei ergänzt.
*
* @param seconds neues Intervall in Sekunden
* @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt
*/
@Override
public void saveIntervalSeconds(int seconds) throws SchedulerSettingsWriteException {
updateProperty(KEY_INTERVAL, String.valueOf(seconds));
}
// -------------------------------------------------------------------------
// Hilfsmethoden: Parsen
// -------------------------------------------------------------------------
private int parseInterval(String raw) {
if (raw == null || raw.isBlank()) {
return SchedulerSettings.DEFAULT_INTERVAL_SECONDS;
}
try {
return Integer.parseInt(raw.trim());
} catch (NumberFormatException e) {
return SchedulerSettings.DEFAULT_INTERVAL_SECONDS;
}
}
// -------------------------------------------------------------------------
// Hilfsmethoden: format-erhaltende Schreiblogik
// -------------------------------------------------------------------------
private void updateProperty(String key, String value) throws SchedulerSettingsWriteException {
try {
byte[] rawBytes = isLocked() ? readAllBytesViaChannel() : Files.readAllBytes(configFile);
String separator = detectLineSeparator(rawBytes);
String rawContent = new String(rawBytes, StandardCharsets.UTF_8);
List<String> lines = splitLines(rawContent, separator);
updateOrAppend(lines, key, value);
String newContent = String.join(separator, lines);
writeContent(newContent);
} catch (IOException e) {
throw new SchedulerSettingsWriteException(
"Einstellung '" + key + "' konnte nicht in "
+ configFile + " geschrieben werden.", e);
}
}
/**
* Liest den vollständigen Dateiinhalt über den gemeinsamen {@link FileChannel}.
* Wird verwendet, wenn ein OS-Lock aktiv ist und {@link Files#readAllBytes} auf
* Windows die gesperrte Datei nicht öffnen kann.
*/
private byte[] readAllBytesViaChannel() throws IOException {
long fileSize = channel.size();
channel.position(0);
ByteArrayOutputStream out = new ByteArrayOutputStream((int) Math.max(fileSize, 0));
ByteBuffer buf = ByteBuffer.allocate(8192);
while (channel.read(buf) != -1) {
buf.flip();
out.write(buf.array(), 0, buf.limit());
buf.clear();
}
return out.toByteArray();
}
/**
* Erkennt das Zeilentrennzeichen anhand der ersten vorkommenden Byte-Sequenz.
* Findet die Methode {@code \r\n}, wird {@code "\r\n"} zurückgegeben;
* andernfalls {@code "\n"}.
*/
private String detectLineSeparator(byte[] rawContent) {
for (int i = 0; i < rawContent.length - 1; i++) {
if (rawContent[i] == '\r' && rawContent[i + 1] == '\n') {
return "\r\n";
}
}
return "\n";
}
private List<String> splitLines(String content, String separator) {
String[] parts = content.split(Pattern.quote(separator), -1);
return new ArrayList<>(Arrays.asList(parts));
}
/**
* Sucht die erste Zeile, die den angegebenen Key definiert, und ersetzt den
* Wert. Wird keine passende Zeile gefunden, wird der Key am Ende der Datei
* eingefügt unmittelbar vor einer abschließenden Leerzeile, sofern vorhanden.
*/
private void updateOrAppend(List<String> lines, String key, String value) {
for (int i = 0; i < lines.size(); i++) {
if (isKeyLine(lines.get(i), key)) {
lines.set(i, key + "=" + value);
return;
}
}
// Key nicht gefunden: vor abschließender Leerzeile einfügen, sonst anhängen.
if (!lines.isEmpty() && lines.get(lines.size() - 1).isBlank()) {
lines.add(lines.size() - 1, key + "=" + value);
} else {
lines.add(key + "=" + value);
}
}
/**
* Prüft, ob die Zeile eine Property-Definition für genau den angegebenen Key
* darstellt. Kommentarzeilen (beginnend mit {@code #} oder {@code !}) werden
* immer als nicht-passend bewertet.
*/
private boolean isKeyLine(String line, String key) {
String trimmed = line.stripLeading();
if (trimmed.startsWith("#") || trimmed.startsWith("!")) {
return false;
}
if (!trimmed.startsWith(key)) {
return false;
}
int afterKey = key.length();
if (afterKey >= trimmed.length()) {
return false; // Zeile enthält nur den Schlüssel ohne Trennzeichen
}
char next = trimmed.charAt(afterKey);
return next == '=' || next == ':' || Character.isWhitespace(next);
}
/**
* Schreibt den Inhalt in die Konfigurationsdatei.
* <p>
* Ist der OS-Lock aktiv, wird über den gemeinsamen {@link FileChannel}
* geschrieben (Truncate Position(0) Write Force). Ist kein Lock aktiv,
* wird eine temporäre Datei erzeugt und danach atomar verschoben.
*/
private void writeContent(String content) throws IOException {
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
if (isLocked()) {
channel.truncate(0);
channel.position(0);
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
} else {
Path tempFile = configFile.resolveSibling(configFile.getFileName() + ".tmp");
Files.writeString(tempFile, content, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
Files.move(tempFile, configFile,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
}
}
private void closeChannelSilently() {
if (channel != null) {
try {
channel.close();
} catch (IOException e) {
logger.warn("Fehler beim Schließen des FileChannel für {}.", configFile, e);
}
channel = null;
}
}
}
@@ -0,0 +1,161 @@
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTrigger;
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerConfig;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerPort;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
* Implementiert {@link SchedulerPort} auf Basis eines
* {@link ScheduledExecutorService} mit
* {@link ScheduledExecutorService#scheduleWithFixedDelay}.
* <p>
* Der erste Tick startet sofort (Initial Delay 0). Nachfolgende Ticks starten
* {@link SchedulerConfig#intervalSeconds()} Sekunden nach dem Ende des
* vorherigen Ticks. Der Verarbeitungsaufruf erfolgt synchron im
* Scheduler-Thread; der aufrufende Tick-Zyklus wartet also auf den Abschluss
* des Laufs, bevor der nächste Tick geplant wird.
* <p>
* Der Adapter delegiert ausschließlich an den injizierten {@link BatchRunTrigger}
* und trifft keine eigenen fachlichen Entscheidungen. Ergebnisse werden über
* den injizierten {@code Consumer<BatchRunTriggerResult>} zurückgemeldet.
* <p>
* Alle Ausnahmen innerhalb eines Ticks werden abgefangen und geloggt, damit
* der {@link ScheduledExecutorService} den Tick-Zyklus nicht still abbricht.
* <p>
* Instanzen dieser Klasse sind für den Einsatz in einem einzigen Steuerungs-Thread
* ausgelegt. {@link #startScheduler} und {@link #stopScheduler} müssen serialisiert
* aufgerufen werden.
*/
public class ScheduledExecutorServiceSchedulerAdapter implements SchedulerPort {
private static final Logger logger =
LogManager.getLogger(ScheduledExecutorServiceSchedulerAdapter.class);
private static final String SCHEDULER_THREAD_NAME = "pdf-umbenenner-scheduler";
private final Consumer<BatchRunTriggerResult> resultConsumer;
/**
* Hält den aktuell aktiven {@link BatchRunTrigger}. Package-private,
* damit Tests {@code onTick()} isoliert prüfen können, ohne den
* gesamten Lifecycle zu durchlaufen.
*/
final AtomicReference<BatchRunTrigger> currentTrigger = new AtomicReference<>();
private final AtomicReference<ScheduledExecutorService> executor = new AtomicReference<>();
/**
* Erstellt einen neuen Adapter.
*
* @param resultConsumer Empfänger für Tick-Ergebnisse; darf nicht {@code null} sein
*/
public ScheduledExecutorServiceSchedulerAdapter(Consumer<BatchRunTriggerResult> resultConsumer) {
this.resultConsumer = Objects.requireNonNull(resultConsumer,
"resultConsumer darf nicht null sein");
}
// -------------------------------------------------------------------------
// SchedulerPort
// -------------------------------------------------------------------------
/**
* Startet den periodischen Scheduler-Mechanismus.
* <p>
* Ist der Scheduler bereits aktiv, hat dieser Aufruf keine Wirkung (idempotent).
* Andernfalls wird ein Single-Thread-{@link ScheduledExecutorService} angelegt
* und mit {@code scheduleWithFixedDelay} und Initial-Delay 0 gestartet.
* Der erzeugte Thread heißt {@value SCHEDULER_THREAD_NAME} und ist kein Daemon-Thread.
*
* @param config Betriebskonfiguration; insbesondere das Intervall zwischen den Ticks
* @param trigger Auslöser, der bei jedem Tick synchron aufgerufen wird
*/
@Override
public void startScheduler(SchedulerConfig config, BatchRunTrigger trigger) {
Objects.requireNonNull(config, "config darf nicht null sein");
Objects.requireNonNull(trigger, "trigger darf nicht null sein");
if (executor.get() != null) {
logger.debug("Scheduler ist bereits aktiv Start-Aufruf wird ignoriert.");
return;
}
currentTrigger.set(trigger);
ThreadFactory threadFactory = runnable -> {
Thread t = new Thread(runnable, SCHEDULER_THREAD_NAME);
t.setDaemon(false);
t.setUncaughtExceptionHandler((thread, ex) ->
logger.error("Unbehandelte Ausnahme im Scheduler-Thread '{}'.",
thread.getName(), ex));
return t;
};
ScheduledExecutorService newExecutor =
Executors.newSingleThreadScheduledExecutor(threadFactory);
newExecutor.scheduleWithFixedDelay(
this::onTick,
0L,
config.intervalSeconds(),
TimeUnit.SECONDS);
executor.set(newExecutor);
logger.info("Scheduler gestartet. Intervall: {} Sekunden.", config.intervalSeconds());
}
/**
* Stoppt den periodischen Scheduler-Mechanismus.
* <p>
* Laufende Ticks werden nicht abgebrochen; es werden lediglich keine weiteren
* Ticks geplant. Ist der Scheduler bereits gestoppt, hat dieser Aufruf keine
* Wirkung (idempotent).
*/
@Override
public void stopScheduler() {
ScheduledExecutorService localExecutor = executor.getAndSet(null);
if (localExecutor == null) {
logger.debug("Scheduler ist bereits gestoppt Stop-Aufruf wird ignoriert.");
return;
}
currentTrigger.set(null);
localExecutor.shutdown();
logger.info("Scheduler angehalten.");
}
// -------------------------------------------------------------------------
// Tick-Logik (package-private für Testbarkeit)
// -------------------------------------------------------------------------
/**
* Führt einen Verarbeitungstick aus.
* <p>
* Holt den aktuellen {@link BatchRunTrigger}, ruft ihn synchron auf und
* leitet das Ergebnis an den {@link Consumer} weiter. Ist kein Trigger
* gesetzt, wird der Tick übersprungen. Alle {@link Exception}en werden
* abgefangen und auf ERROR geloggt, damit der
* {@link ScheduledExecutorService} den Tick-Zyklus nicht still abbricht.
* <p>
* Package-private, damit Unit-Tests diese Methode direkt aufrufen können.
*/
void onTick() {
BatchRunTrigger trigger = currentTrigger.get();
if (trigger == null) {
logger.warn("Scheduler-Tick ausgelöst, aber kein aktiver Trigger vorhanden. "
+ "Tick wird übersprungen.");
return;
}
try {
BatchRunTriggerResult result = trigger.triggerRun();
resultConsumer.accept(result);
} catch (Exception e) {
logger.error("Unbehandelte Ausnahme während des Scheduler-Ticks. "
+ "Der nächste Tick wird planmäßig ausgelöst.", e);
}
}
}
@@ -0,0 +1,15 @@
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
/**
* Platzhalter-Klasse, die sicherstellt, dass der Compiler das Modul
* nicht als leer behandelt.
* <p>
* Diese Klasse wird durch die echte Adapter-Implementierung ersetzt,
* sobald der Scheduler-Adapter implementiert wird.
*/
class SchedulerPlaceholder {
private SchedulerPlaceholder() {
// Nicht instanziierbar; wird durch echte Klassen ersetzt.
}
}
@@ -0,0 +1,10 @@
/**
* Inbound-Adapter für den autonomen Scheduler-Betrieb.
* <p>
* Dieses Paket enthält den Adapter, der die periodische automatische
* Verarbeitung von PDF-Dateien ohne Benutzerinteraktion steuert.
* Der Adapter wird durch das Bootstrap-Modul verdrahtet und gestartet.
* Er ist ausschließlich vom Application-Modul abhängig und kennt weder
* JavaFX noch Bootstrap-interne Typen.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
@@ -0,0 +1,251 @@
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
/**
* Unit-Tests für {@link FileChannelConfigurationAccessAdapter}.
*/
class FileChannelConfigurationAccessAdapterTest {
@Test
void isLocked_returnsFalseBeforeAnyAcquire(@TempDir Path tempDir) throws IOException {
Path config = createConfigFile(tempDir, "");
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
assertThat(adapter.isLocked()).isFalse();
}
@Test
void acquireLock_setsIsLockedTrue(@TempDir Path tempDir) throws IOException {
Path config = createConfigFile(tempDir, "");
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
adapter.acquireLock();
try {
assertThat(adapter.isLocked()).isTrue();
} finally {
adapter.releaseLock();
}
}
@Test
void releaseLock_setsIsLockedFalse(@TempDir Path tempDir) throws IOException {
Path config = createConfigFile(tempDir, "");
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
adapter.acquireLock();
adapter.releaseLock();
assertThat(adapter.isLocked()).isFalse();
}
@Test
void acquireLock_calledTwice_isIdempotent(@TempDir Path tempDir) throws IOException {
Path config = createConfigFile(tempDir, "");
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
adapter.acquireLock();
try {
assertThatCode(adapter::acquireLock).doesNotThrowAnyException();
assertThat(adapter.isLocked()).isTrue();
} finally {
adapter.releaseLock();
}
}
@Test
void releaseLock_calledTwice_isIdempotent(@TempDir Path tempDir) throws IOException {
Path config = createConfigFile(tempDir, "");
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
adapter.acquireLock();
adapter.releaseLock();
assertThatCode(adapter::releaseLock).doesNotThrowAnyException();
assertThat(adapter.isLocked()).isFalse();
}
@Test
void releaseLock_withoutPriorAcquire_doesNotThrow(@TempDir Path tempDir) throws IOException {
Path config = createConfigFile(tempDir, "");
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
assertThatCode(adapter::releaseLock).doesNotThrowAnyException();
}
@Test
void acquireLock_throwsConfigurationFileLockException_whenFileDoesNotExist(
@TempDir Path tempDir) {
Path nonExistent = tempDir.resolve("missing.properties");
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(nonExistent);
assertThatThrownBy(adapter::acquireLock)
.isInstanceOf(ConfigurationFileLockException.class);
}
@Test
void loadSettings_returnsDefaultsWhenKeysAreMissing(@TempDir Path tempDir) throws IOException {
Path config = createConfigFile(tempDir, "source.folder=S:\\source\n");
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
SchedulerSettings settings = adapter.loadSettings();
assertThat(settings.intervalSeconds()).isEqualTo(SchedulerSettings.DEFAULT_INTERVAL_SECONDS);
}
@Test
void loadSettings_returnsConfiguredValues(@TempDir Path tempDir) throws IOException {
String content = "scheduler.interval.seconds=300\n";
Path config = createConfigFile(tempDir, content);
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
SchedulerSettings settings = adapter.loadSettings();
assertThat(settings.intervalSeconds()).isEqualTo(300);
}
@Test
void loadSettings_returnsDefaultIntervalForNonNumericValue(@TempDir Path tempDir)
throws IOException {
String content = "scheduler.interval.seconds=not-a-number\n";
Path config = createConfigFile(tempDir, content);
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
SchedulerSettings settings = adapter.loadSettings();
assertThat(settings.intervalSeconds()).isEqualTo(SchedulerSettings.DEFAULT_INTERVAL_SECONDS);
}
@Test
void loadSettings_returnsDefaultsWhenFileIsEmpty(@TempDir Path tempDir) throws IOException {
Path config = createConfigFile(tempDir, "");
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
SchedulerSettings settings = adapter.loadSettings();
assertThat(settings).isEqualTo(SchedulerSettings.defaults());
}
@Test
void saveIntervalSeconds_updatesExistingKeyAndPreservesOtherLines(@TempDir Path tempDir)
throws IOException {
String initial = "source.folder=/opt/source\nscheduler.interval.seconds=180\ntarget.folder=/opt/target\n";
Path config = createConfigFile(tempDir, initial);
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
adapter.saveIntervalSeconds(300);
Properties props = loadProperties(config);
assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("300");
assertThat(props.getProperty("source.folder")).isEqualTo("/opt/source");
assertThat(props.getProperty("target.folder")).isEqualTo("/opt/target");
}
@Test
void saveIntervalSeconds_appendsKeyWhenMissing(@TempDir Path tempDir) throws IOException {
String initial = "source.folder=/opt/source\n";
Path config = createConfigFile(tempDir, initial);
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
adapter.saveIntervalSeconds(240);
Properties props = loadProperties(config);
assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("240");
assertThat(props.getProperty("source.folder")).isEqualTo("/opt/source");
}
@Test
void saveIntervalSeconds_writesCorrectlyThroughChannelWhenLocked(@TempDir Path tempDir)
throws IOException {
String initial = "scheduler.interval.seconds=180\n";
Path config = createConfigFile(tempDir, initial);
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
adapter.acquireLock();
try {
adapter.saveIntervalSeconds(300);
} finally {
adapter.releaseLock();
}
Properties props = loadProperties(config);
assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("300");
}
@Test
void saveIntervalSeconds_preservesCrlfLineEndings(@TempDir Path tempDir) throws IOException {
String initial = "scheduler.interval.seconds=180\r\nother.key=value\r\n";
Path config = createConfigFileBinary(tempDir, initial.getBytes(StandardCharsets.UTF_8));
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
adapter.saveIntervalSeconds(300);
byte[] resultBytes = Files.readAllBytes(config);
String result = new String(resultBytes, StandardCharsets.UTF_8);
assertThat(result).contains("scheduler.interval.seconds=300\r\n");
assertThat(result).contains("other.key=value\r\n");
}
@Test
void saveIntervalSeconds_preservesLfLineEndings(@TempDir Path tempDir) throws IOException {
String initial = "scheduler.interval.seconds=180\nother.key=value\n";
Path config = createConfigFile(tempDir, initial);
FileChannelConfigurationAccessAdapter adapter =
new FileChannelConfigurationAccessAdapter(config);
adapter.saveIntervalSeconds(300);
String result = Files.readString(config, StandardCharsets.UTF_8);
assertThat(result).contains("scheduler.interval.seconds=300\n");
assertThat(result).contains("other.key=value\n");
assertThat(result).doesNotContain("\r\n");
}
private static Path createConfigFile(Path tempDir, String content) throws IOException {
Path config = tempDir.resolve("test.properties");
Files.writeString(config, content, StandardCharsets.UTF_8);
return config;
}
private static Path createConfigFileBinary(Path tempDir, byte[] bytes) throws IOException {
Path config = tempDir.resolve("test.properties");
Files.write(config, bytes);
return config;
}
private static Properties loadProperties(Path file) throws IOException {
Properties props = new Properties();
props.load(new StringReader(Files.readString(file, StandardCharsets.UTF_8)));
return props;
}
}
@@ -0,0 +1,244 @@
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult;
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerConfig;
/**
* Unit- und Integrationstests für {@link ScheduledExecutorServiceSchedulerAdapter}.
* <p>
* Teststrategien:
* <ul>
* <li>Lifecycle-Tests (Start, Stop, Idempotenz) nutzen {@link CountDownLatch}
* für deterministische Synchronisation ohne {@code Thread.sleep}.</li>
* <li>Tick-Logik-Tests ({@code onTick}) rufen die package-private Methode
* direkt auf und setzen {@code currentTrigger} ohne Executor.</li>
* </ul>
*/
class ScheduledExecutorServiceSchedulerAdapterTest {
// =========================================================================
// Lifecycle: startScheduler
// =========================================================================
@Test
void startScheduler_triggersFirstTickImmediately() throws Exception {
List<BatchRunTriggerResult> results = new CopyOnWriteArrayList<>();
CountDownLatch latch = new CountDownLatch(1);
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(result -> {
results.add(result);
latch.countDown();
});
SchedulerConfig config = new SchedulerConfig(3600);
adapter.startScheduler(config, () -> new BatchRunTriggerResult.SkippedBusy());
try {
assertThat(latch.await(5, TimeUnit.SECONDS))
.as("Erster Tick muss innerhalb von 5 Sekunden ausgelöst werden")
.isTrue();
assertThat(results).hasSize(1);
assertThat(results.get(0)).isInstanceOf(BatchRunTriggerResult.SkippedBusy.class);
} finally {
adapter.stopScheduler();
}
}
@Test
void startScheduler_isIdempotent_secondCallDoesNotCreateSecondExecutor() throws Exception {
List<BatchRunTriggerResult> results = new CopyOnWriteArrayList<>();
CountDownLatch latch = new CountDownLatch(1);
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(result -> {
results.add(result);
latch.countDown();
});
SchedulerConfig config = new SchedulerConfig(3600);
adapter.startScheduler(config, () -> new BatchRunTriggerResult.SkippedBusy());
adapter.startScheduler(config, () -> new BatchRunTriggerResult.SkippedBusy()); // no-op
try {
latch.await(5, TimeUnit.SECONDS);
// Kurze Wartezeit: ein zweiter Executor würde sofort einen zweiten Tick feuern
Thread.sleep(100);
assertThat(results)
.as("Nur ein Executor → genau ein sofortiger Tick mit Intervall 3600s")
.hasSize(1);
} finally {
adapter.stopScheduler();
}
}
@Test
void startScheduler_afterStop_canBeRestartedWithNewTrigger() throws Exception {
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(result -> {});
CountDownLatch firstLatch = new CountDownLatch(1);
CountDownLatch secondLatch = new CountDownLatch(1);
SchedulerConfig config = new SchedulerConfig(3600);
adapter.startScheduler(config, () -> {
firstLatch.countDown();
return new BatchRunTriggerResult.SkippedBusy();
});
firstLatch.await(5, TimeUnit.SECONDS);
adapter.stopScheduler();
List<BatchRunTriggerResult> secondResults = new CopyOnWriteArrayList<>();
adapter.startScheduler(config, () -> {
BatchRunTriggerResult r =
new BatchRunTriggerResult.Started(Instant.now(), RunSummary.noOp());
secondResults.add(r);
secondLatch.countDown();
return r;
});
try {
assertThat(secondLatch.await(5, TimeUnit.SECONDS))
.as("Zweiter Start muss einen Tick auslösen")
.isTrue();
assertThat(secondResults.get(0)).isInstanceOf(BatchRunTriggerResult.Started.class);
} finally {
adapter.stopScheduler();
}
}
// =========================================================================
// Lifecycle: stopScheduler
// =========================================================================
@Test
void stopScheduler_withoutPriorStart_doesNotThrow() {
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(result -> {});
assertThatCode(adapter::stopScheduler).doesNotThrowAnyException();
}
@Test
void stopScheduler_calledTwice_isIdempotent() throws Exception {
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(result -> {});
CountDownLatch latch = new CountDownLatch(1);
adapter.startScheduler(new SchedulerConfig(3600), () -> {
latch.countDown();
return new BatchRunTriggerResult.SkippedBusy();
});
latch.await(5, TimeUnit.SECONDS);
adapter.stopScheduler();
assertThatCode(adapter::stopScheduler)
.as("Zweiter Stop-Aufruf darf keine Ausnahme werfen")
.doesNotThrowAnyException();
}
// =========================================================================
// Tick-Logik: onTick (direkte Aufrufe, kein Executor)
// =========================================================================
@Test
void onTick_whenTriggerIsNull_doesNotCallConsumer() {
List<BatchRunTriggerResult> results = new ArrayList<>();
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(results::add);
// Kein startScheduler currentTrigger ist null
adapter.onTick();
assertThat(results).isEmpty();
}
@Test
void onTick_whenTriggerReturnsSkippedBusy_passesResultToConsumer() {
List<BatchRunTriggerResult> results = new ArrayList<>();
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(results::add);
adapter.currentTrigger.set(() -> new BatchRunTriggerResult.SkippedBusy());
adapter.onTick();
assertThat(results).hasSize(1);
assertThat(results.get(0)).isInstanceOf(BatchRunTriggerResult.SkippedBusy.class);
}
@Test
void onTick_whenTriggerReturnsStarted_passesResultToConsumer() {
List<BatchRunTriggerResult> results = new ArrayList<>();
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(results::add);
Instant now = Instant.now();
RunSummary summary = new RunSummary(2, 1, 0);
adapter.currentTrigger.set(() -> new BatchRunTriggerResult.Started(now, summary));
adapter.onTick();
assertThat(results).hasSize(1);
BatchRunTriggerResult.Started started =
(BatchRunTriggerResult.Started) results.get(0);
assertThat(started.endedAt()).isEqualTo(now);
assertThat(started.summary()).isEqualTo(summary);
}
@Test
void onTick_whenTriggerThrowsException_exceptionIsSwallowed() {
List<BatchRunTriggerResult> results = new ArrayList<>();
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(results::add);
adapter.currentTrigger.set(() -> {
throw new RuntimeException("Simulierter Trigger-Fehler");
});
assertThatCode(adapter::onTick)
.as("Ausnahme im Trigger darf nicht aus onTick propagieren")
.doesNotThrowAnyException();
assertThat(results)
.as("Consumer darf nicht aufgerufen werden, wenn der Trigger wirft")
.isEmpty();
}
@Test
void onTick_whenConsumerThrowsException_exceptionIsSwallowed() {
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(result -> {
throw new RuntimeException("Simulierter Consumer-Fehler");
});
adapter.currentTrigger.set(() -> new BatchRunTriggerResult.SkippedBusy());
assertThatCode(adapter::onTick)
.as("Ausnahme im Consumer darf nicht aus onTick propagieren")
.doesNotThrowAnyException();
}
@Test
void onTick_calledMultipleTimes_passesEachResultToConsumer() {
List<BatchRunTriggerResult> results = new ArrayList<>();
ScheduledExecutorServiceSchedulerAdapter adapter =
new ScheduledExecutorServiceSchedulerAdapter(results::add);
adapter.currentTrigger.set(() -> new BatchRunTriggerResult.SkippedBusy());
adapter.onTick();
adapter.onTick();
adapter.onTick();
assertThat(results).hasSize(3);
}
}
+4
View File
@@ -31,6 +31,10 @@
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
@@ -14,6 +14,7 @@ import org.json.JSONException;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
@@ -356,7 +357,8 @@ public class AnthropicClaudeHttpAdapter implements AiInvocationPort {
"Anthropic response contained no text-type content blocks");
}
return new AiInvocationSuccess(request, new AiRawResponse(extractedText));
return new AiInvocationSuccess(request, new AiRawResponse(extractedText),
extractTokenUsageFromResponse(json));
} catch (JSONException e) {
LOG.warn("Claude AI response could not be parsed as JSON: {}", e.getMessage());
return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
@@ -364,6 +366,64 @@ public class AnthropicClaudeHttpAdapter implements AiInvocationPort {
}
}
/**
* Extrahiert Token-Verbrauchsmetadaten aus der Anthropic-Response.
*
* <p>Anthropic Messages API liefert im Top-Level-Feld {@code usage}:
* {@code input_tokens}, {@code output_tokens},
* {@code cache_creation_input_tokens}, {@code cache_read_input_tokens}.
*
* <p>Validierung: nicht-numerische, negative oder ueber 10 Mio. liegende
* Werte werden auf {@code null} gesetzt und mit WARN-Log markiert.
*
* @param root die geparste Anthropic-Response
* @return befuelltes {@link AiUsageMetadata}; nie {@code null}
*/
private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
JSONObject usage = root.optJSONObject("usage");
if (usage == null) {
LOG.warn("Anthropic-Response enthielt kein usage-Feld Token-Daten werden nicht erfasst");
return AiUsageMetadata.empty();
}
Long inputTokens = readTokenField(usage, "input_tokens");
Long outputTokens = readTokenField(usage, "output_tokens");
Long cacheCreation = readTokenField(usage, "cache_creation_input_tokens");
Long cacheRead = readTokenField(usage, "cache_read_input_tokens");
return new AiUsageMetadata(inputTokens, outputTokens, cacheCreation, cacheRead);
}
/**
* Liest und validiert einen einzelnen Token-Wert aus einem JSON-Objekt.
*
* <p>Akzeptiert nicht-vorhandene Felder ({@code null}-Rueckgabe ohne Log).
* Verwirft nicht-numerische, negative oder ueber 10 Mio. liegende Werte
* mit WARN-Log und gibt {@code null} zurueck.
*
* @param usage das JSON-Objekt mit den Token-Feldern
* @param fieldName Name des Feldes
* @return validierter Token-Wert oder {@code null}
*/
private Long readTokenField(JSONObject usage, String fieldName) {
if (!usage.has(fieldName) || usage.isNull(fieldName)) {
return null;
}
try {
long value = usage.getLong(fieldName);
if (value < 0L) {
LOG.warn("Anthropic-Token-Feld {} ist negativ ({}) Wert verworfen", fieldName, value);
return null;
}
if (value > 10_000_000L) {
LOG.warn("Anthropic-Token-Feld {} uebersteigt Maximum (10 Mio.): {} Wert verworfen", fieldName, value);
return null;
}
return value;
} catch (JSONException e) {
LOG.warn("Anthropic-Token-Feld {} ist nicht numerisch Wert verworfen: {}", fieldName, e.getMessage());
return null;
}
}
/**
* Package-private accessor for the last constructed JSON body.
* <p>
@@ -14,6 +14,7 @@ import org.json.JSONException;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
@@ -95,6 +96,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
* </ul>
*/
public class OpenAiHttpAdapter implements AiInvocationPort {
private static final String NO_CHOICE_CONTENT_SENTINEL = "NO_CHOICE_CONTENT";
private static final String JSON_KEY_CONTENT = "content";
private static final Logger LOG = LogManager.getLogger(OpenAiHttpAdapter.class);
@@ -248,23 +253,24 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
JSONArray choices = json.optJSONArray("choices");
if (choices == null || choices.isEmpty()) {
LOG.warn("OpenAI response contained no choices");
return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT",
return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
"OpenAI response contained no choices");
}
JSONObject firstChoice = choices.getJSONObject(0);
JSONObject message = firstChoice.optJSONObject("message");
if (message == null) {
LOG.warn("OpenAI response choice contained no message");
return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT",
return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
"OpenAI response choice contained no message");
}
String content = message.optString("content", null);
String content = message.optString(JSON_KEY_CONTENT, null);
if (content == null || content.isBlank()) {
LOG.warn("OpenAI response message.content is absent or blank");
return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT",
return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
"OpenAI response message.content is absent or blank");
}
return new AiInvocationSuccess(request, new AiRawResponse(content));
return new AiInvocationSuccess(request, new AiRawResponse(content),
extractTokenUsageFromResponse(json));
} catch (JSONException e) {
LOG.warn("OpenAI response could not be parsed as JSON: {}", e.getMessage());
return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
@@ -272,6 +278,63 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
}
}
/**
* Extrahiert Token-Verbrauchsmetadaten aus der OpenAI-Response.
*
* <p>Mapping: {@code usage.prompt_tokens -> inputTokens},
* {@code usage.completion_tokens -> outputTokens}. Cache-Felder sind in der
* OpenAI-kompatiblen Schnittstelle nicht standardisiert und bleiben immer
* {@code null}.
*
* <p>Validierung: nicht-numerische, negative oder ueber 10 Mio. liegende
* Werte werden auf {@code null} gesetzt und mit WARN-Log markiert.
*
* @param root die geparste OpenAI-Response (Envelope)
* @return befuelltes {@link AiUsageMetadata}; nie {@code null}
*/
private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
JSONObject usage = root.optJSONObject("usage");
if (usage == null) {
LOG.warn("OpenAI-Response enthielt kein usage-Feld Token-Daten werden nicht erfasst");
return AiUsageMetadata.empty();
}
Long inputTokens = readTokenField(usage, "prompt_tokens");
Long outputTokens = readTokenField(usage, "completion_tokens");
return new AiUsageMetadata(inputTokens, outputTokens, null, null);
}
/**
* Liest und validiert einen einzelnen Token-Wert aus einem JSON-Objekt.
*
* <p>Akzeptiert nicht-vorhandene Felder ({@code null}-Rueckgabe ohne Log).
* Verwirft nicht-numerische, negative oder ueber 10 Mio. liegende Werte
* mit WARN-Log und gibt {@code null} zurueck.
*
* @param usage das JSON-Objekt mit den Token-Feldern
* @param fieldName Name des Feldes
* @return validierter Token-Wert oder {@code null}
*/
private Long readTokenField(JSONObject usage, String fieldName) {
if (!usage.has(fieldName) || usage.isNull(fieldName)) {
return null;
}
try {
long value = usage.getLong(fieldName);
if (value < 0L) {
LOG.warn("OpenAI-Token-Feld {} ist negativ ({}) Wert verworfen", fieldName, value);
return null;
}
if (value > 10_000_000L) {
LOG.warn("OpenAI-Token-Feld {} uebersteigt Maximum (10 Mio.): {} Wert verworfen", fieldName, value);
return null;
}
return value;
} catch (JSONException e) {
LOG.warn("OpenAI-Token-Feld {} ist nicht numerisch Wert verworfen: {}", fieldName, e.getMessage());
return null;
}
}
/**
* Builds an OpenAI Chat Completions API request from the request representation.
* <p>
@@ -347,11 +410,11 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
JSONObject systemMessage = new JSONObject();
systemMessage.put("role", "system");
systemMessage.put("content", request.promptContent());
systemMessage.put(JSON_KEY_CONTENT, request.promptContent());
JSONObject userMessage = new JSONObject();
userMessage.put("role", "user");
userMessage.put("content", request.documentText());
userMessage.put(JSON_KEY_CONTENT, request.documentText());
body.put("messages", new org.json.JSONArray()
.put(systemMessage)
@@ -4,22 +4,26 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
/**
* File-based implementation of {@link RunLockPort} that uses a lock file to prevent concurrent runs.
* Dateibasierte Implementierung von {@link RunLockPort}.
* <p>
* Creates an exclusive lock file on acquire and deletes it on release.
* If the lock file already exists, {@link #acquire()} throws {@link RunLockUnavailableException}
* to signal that another instance is already running.
* Verwendet eine Lock-Datei, um parallele Läufe zu verhindern.
* Beim Erwerb wird die Lock-Datei angelegt; bei der Freigabe wird sie gelöscht.
* Existiert die Datei bereits, ist der Lock belegt.
* <p>
* The lock file contains the PID of the acquiring process. Release is best-effort: a failure
* to delete the lock file is logged as a warning but does not throw.
* Die Lock-Datei enthält die PID des erwerbenden Prozesses.
* Die Freigabe ist best-effort: Ein Fehler beim Löschen wird als Warnung
* geloggt, wirft aber keine Ausnahme.
*/
public class FilesystemRunLockPortAdapter implements RunLockPort {
@@ -28,27 +32,31 @@ public class FilesystemRunLockPortAdapter implements RunLockPort {
private final Path lockFile;
/**
* Creates a new FilesystemRunLockPortAdapter for the given lock file path.
* Erstellt einen neuen {@code FilesystemRunLockPortAdapter} für den
* angegebenen Lock-Datei-Pfad.
*
* @param lockFile path of the lock file to create on acquire and delete on release
* @param lockFile Pfad der Lock-Datei, die beim Erwerb angelegt und
* bei der Freigabe gelöscht wird
*/
public FilesystemRunLockPortAdapter(Path lockFile) {
this.lockFile = lockFile;
}
/**
* Acquires the run lock by creating the lock file.
* Erwirbt den Run-Lock durch Anlegen der Lock-Datei (blockierend).
* <p>
* If the lock file already exists, throws {@link RunLockUnavailableException}.
* If the parent directory does not exist, it is created before attempting file creation.
* Existiert die Lock-Datei bereits, wird eine
* {@link RunLockUnavailableException} geworfen. Das übergeordnete
* Verzeichnis wird bei Bedarf angelegt.
*
* @throws RunLockUnavailableException if the lock file already exists or cannot be created
* @throws RunLockUnavailableException wenn die Lock-Datei bereits existiert
* oder nicht angelegt werden kann
*/
@Override
public void acquire() {
if (Files.exists(lockFile)) {
throw new RunLockUnavailableException(
"Run lock file already exists - another instance may be running: " + lockFile);
"Run-Lock-Datei existiert bereits eine andere Instanz könnte laufen: " + lockFile);
}
try {
Path parent = lockFile.getParent();
@@ -57,26 +65,83 @@ public class FilesystemRunLockPortAdapter implements RunLockPort {
}
long pid = ProcessHandle.current().pid();
Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW);
LOG.debug("Run lock acquired: {} (PID {})", lockFile, pid);
LOG.debug("Run-Lock erworben: {} (PID {})", lockFile, pid);
} catch (IOException e) {
throw new RunLockUnavailableException("Failed to acquire run lock file: " + lockFile, e);
throw new RunLockUnavailableException("Run-Lock-Datei konnte nicht angelegt werden: " + lockFile, e);
}
}
/**
* Releases the run lock by deleting the lock file.
* Gibt den Run-Lock durch Löschen der Lock-Datei frei.
* <p>
* If deletion fails, a warning is logged but no exception is thrown.
* Schlägt das Löschen fehl, wird eine Warnung geloggt; keine Ausnahme
* wird geworfen.
*/
@Override
public void release() {
try {
boolean deleted = Files.deleteIfExists(lockFile);
if (deleted) {
LOG.debug("Run lock released: {}", lockFile);
LOG.debug("Run-Lock freigegeben: {}", lockFile);
}
} catch (IOException e) {
LOG.warn("Failed to release run lock file: {} manual cleanup may be required", lockFile, e);
LOG.warn("Run-Lock-Datei konnte nicht gelöscht werden: {} manuelle Bereinigung erforderlich",
lockFile, e);
}
}
/**
* Versucht nicht-blockierend, den Run-Lock zu erwerben.
* <p>
* Existiert die Lock-Datei bereits, wird sofort {@link Optional#empty()}
* zurückgegeben. Andernfalls wird die Datei atomar mit
* {@link StandardOpenOption#CREATE_NEW} angelegt. Schlägt das Anlegen
* aufgrund einer Race-Condition fehl (z.B. gleichzeitiger Erwerb durch
* eine andere Instanz), wird ebenfalls {@link Optional#empty()} zurückgegeben.
* <p>
* Das zurückgegebene {@link RunLockHandle} gibt den Lock idempotent frei.
*
* @return Handle mit dem erworbenen Lock, oder {@link Optional#empty()}
* wenn der Lock nicht verfügbar ist
*/
@Override
public Optional<RunLockHandle> tryAcquire() {
if (Files.exists(lockFile)) {
LOG.debug("Run-Lock nicht verfügbar (Datei existiert): {}", lockFile);
return Optional.empty();
}
try {
Path parent = lockFile.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
long pid = ProcessHandle.current().pid();
Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW);
LOG.debug("Run-Lock (tryAcquire) erworben: {} (PID {})", lockFile, pid);
return Optional.of(new FilesystemRunLockHandle());
} catch (IOException e) {
// CREATE_NEW schlägt mit FileAlreadyExistsException fehl wenn eine
// Race-Condition vorliegt kein Fehler, sondern normaler Busy-Zustand
LOG.debug("Run-Lock (tryAcquire) nicht verfügbar: {} {}", lockFile, e.getMessage());
return Optional.empty();
}
}
/**
* Handle für einen über {@link #tryAcquire()} erworbenen Run-Lock.
* <p>
* Gibt den Lock idempotent frei. Mehrfaches Aufrufen von {@link #close()}
* hat nach dem ersten Aufruf keine Wirkung.
*/
private class FilesystemRunLockHandle implements RunLockHandle {
private final AtomicBoolean released = new AtomicBoolean(false);
@Override
public void close() {
if (released.compareAndSet(false, true)) {
FilesystemRunLockPortAdapter.this.release();
}
}
}
}
@@ -46,6 +46,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalog
* </ul>
*/
public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
private static final String FAILURE_CODE_CONNECTION = "CONNECTION_FAILURE";
private static final String FAILURE_CODE_UNKNOWN = "UNKNOWN";
private static final Logger LOG = LogManager.getLogger(ClaudeModelCatalogAdapter.class);
@@ -133,28 +137,28 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
} catch (java.net.http.HttpTimeoutException e) {
LOG.warn("Claude model catalogue: request timed out {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
LOG.warn("Claude model catalogue: connection failed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
LOG.warn("Claude model catalogue: hostname not resolvable {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
LOG.warn("Claude model catalogue: IO error {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("Claude model catalogue: request interrupted");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("Claude model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter Fehler: " + e.getMessage());
}
}
@@ -188,7 +192,7 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
if (status != 200) {
LOG.warn("Claude model catalogue: unexpected HTTP status {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter HTTP-Status: " + status);
}
@@ -291,24 +295,24 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("Claude model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter Fehler: " + e.getMessage());
}
}
@@ -46,6 +46,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalog
* </ul>
*/
public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
private static final String FAILURE_CODE_CONNECTION = "CONNECTION_FAILURE";
private static final String FAILURE_CODE_UNKNOWN = "UNKNOWN";
private static final Logger LOG = LogManager.getLogger(OpenAiCompatibleModelCatalogAdapter.class);
@@ -129,28 +133,28 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
} catch (java.net.http.HttpTimeoutException e) {
LOG.warn("OpenAI-compatible model catalogue: request timed out {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
LOG.warn("OpenAI-compatible model catalogue: connection failed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
LOG.warn("OpenAI-compatible model catalogue: hostname not resolvable {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
LOG.warn("OpenAI-compatible model catalogue: IO error {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("OpenAI-compatible model catalogue: request interrupted");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter Fehler: " + e.getMessage());
}
}
@@ -184,7 +188,7 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
if (status != 200) {
LOG.warn("OpenAI-compatible model catalogue: unexpected HTTP status {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter HTTP-Status: " + status);
}
@@ -285,24 +289,24 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter Fehler: " + e.getMessage());
}
}
@@ -2,9 +2,12 @@ package de.gecheckt.pdf.umbenenner.adapter.out.prompt;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
import java.util.UUID;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -13,45 +16,61 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
/**
* Filesystem-based implementation of {@link PromptPort}.
* Dateisystembasierte Implementierung von {@link PromptPort}.
* <p>
* Loads prompt templates from an external file on disk and derives a stable identifier
* from the filename. Ensures that empty or technically unusable prompts are rejected.
* Lädt Prompt-Templates aus einer externen Datei auf dem Datenträger und leitet einen
* stabilen Identifikator aus dem Dateinamen ab. Stellt sicher, dass leere oder technisch
* unbrauchbare Prompts abgelehnt werden.
* <p>
* <strong>Identifier derivation:</strong>
* The stable prompt identifier is derived from the filename of the prompt file.
* This ensures deterministic, reproducible identification across batch runs.
* For example, a prompt file named {@code "prompt_de_v2.txt"} receives the identifier
* <strong>Identifikatorableitung:</strong>
* Der stabile Identifikator wird aus dem Dateinamen der Prompt-Datei abgeleitet.
* Eine Prompt-Datei namens {@code "prompt_de_v2.txt"} erhält den Identifikator
* {@code "prompt_de_v2.txt"}.
* <p>
* <strong>Content validation:</strong>
* After loading, the prompt content is trimmed and validated to ensure it is not empty.
* An empty prompt (or one containing only whitespace) is considered technically unusable
* and results in a {@link PromptLoadingFailure}.
* <strong>Inhaltsprüfung:</strong>
* Nach dem Laden wird der Inhalt getrimmt und auf Leerheit geprüft. Ein leerer Prompt
* (oder einer, der nur Leerzeichen enthält) gilt als technisch unbrauchbar und führt zu
* {@link PromptLoadingFailure}.
* <p>
* <strong>Error handling:</strong>
* All technical failures (file not found, I/O errors, permission issues) are caught
* and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
* <strong>Atomares Speichern:</strong>
* {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei <em>im selben
* Verzeichnis</em> wie die Zieldatei und verschiebt diese danach atomar via
* {@code ATOMIC_MOVE}. Bei einem Fehler beim atomaren Verschieben wird kein stiller
* Fallback auf nicht-atomares Schreiben durchgeführt.
* <p>
* <strong>Fehlerbehandlung:</strong>
* Alle technischen Fehler (Datei nicht gefunden, I/O-Fehler, fehlende Berechtigungen)
* werden abgefangen und als strukturierte Ergebnistypen zurückgegeben keine Exceptions
* werden propagiert.
*/
public class FilesystemPromptPortAdapter implements PromptPort {
private static final String SAVE_FAILED_LOG_MSG = "Prompt speichern fehlgeschlagen: {}";
private static final Logger LOG = LogManager.getLogger(FilesystemPromptPortAdapter.class);
private final Path promptFilePath;
/**
* Creates the adapter with the configured prompt file path.
* Erstellt den Adapter mit dem konfigurierten Pfad zur Prompt-Datei.
*
* @param promptFilePath the path to the prompt template file; must not be null
* @throws NullPointerException if promptFilePath is null
* @param promptFilePath Pfad zur Prompt-Template-Datei; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code promptFilePath} null ist
*/
public FilesystemPromptPortAdapter(Path promptFilePath) {
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
}
/**
* Lädt das konfigurierte Prompt-Template aus der Datei.
*
* @return {@link PromptLoadingResult} mit dem geladenen Inhalt oder einem klassifizierten Fehler;
* nie {@code null}
*/
@Override
public PromptLoadingResult loadPrompt() {
try {
@@ -71,11 +90,11 @@ public class FilesystemPromptPortAdapter implements PromptPort {
}
PromptIdentifier identifier = deriveIdentifier();
LOG.debug("Prompt loaded successfully from {}", promptFilePath);
LOG.debug("Prompt erfolgreich geladen von {}", promptFilePath);
return new PromptLoadingSuccess(identifier, trimmedContent);
} catch (IOException e) {
LOG.error("Failed to load prompt file: {}", promptFilePath, e);
LOG.error("Fehler beim Laden der Prompt-Datei: {}", promptFilePath, e);
return new PromptLoadingFailure(
"IO_ERROR",
"Failed to read prompt file: " + e.getMessage());
@@ -83,15 +102,88 @@ public class FilesystemPromptPortAdapter implements PromptPort {
}
/**
* Derives a stable prompt identifier from the filename.
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
* <p>
* The identifier is simply the filename (without the directory path).
* This ensures that the same prompt file always receives the same identifier.
* Der Ablauf:
* <ol>
* <li>Prüfen, ob der Zielordner existiert.</li>
* <li>Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen.</li>
* <li>Inhalt in UTF-8 in die temporäre Datei schreiben.</li>
* <li>Temporäre Datei via {@code ATOMIC_MOVE} zur Zieldatei verschieben.</li>
* <li>Bei Fehler: temporäre Datei aufräumen, Fehler als Ergebnis zurückgeben.</li>
* </ol>
* <p>
* Zeilenenden werden unverändert übernommen. Es findet keine Normalisierung statt.
*
* @return a stable PromptIdentifier based on the filename
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @throws NullPointerException wenn {@code content} null ist
*/
@Override
public PromptSaveResult savePrompt(String content) {
Objects.requireNonNull(content, "content must not be null");
Path targetDir = promptFilePath.getParent();
if (targetDir == null || !Files.isDirectory(targetDir)) {
String message = "Zielordner der Prompt-Datei existiert nicht: "
+ (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
LOG.warn(SAVE_FAILED_LOG_MSG, message);
return new PromptSaveResult.TargetDirectoryMissing(message);
}
// Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen
// (nicht im System-Temp ATOMIC_MOVE funktioniert nicht zuverlässig über Dateisystem-Grenzen)
Path tempFile = targetDir.resolve(".prompt-tmp-" + UUID.randomUUID() + ".tmp");
try {
Files.write(tempFile, content.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
beräumeTempDatei(tempFile);
String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
LOG.warn(SAVE_FAILED_LOG_MSG, message, e);
return new PromptSaveResult.WriteFailed(message, e);
}
// Atomares Verschieben kein stiller Fallback auf nicht-atomares Move
try {
Files.move(tempFile, promptFilePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
LOG.info("Prompt-Datei erfolgreich gespeichert: {}", promptFilePath.toAbsolutePath());
return new PromptSaveResult.Saved(promptFilePath.toAbsolutePath().toString());
} catch (AtomicMoveNotSupportedException e) {
beräumeTempDatei(tempFile);
String message = "Atomares Verschieben der Prompt-Datei wird vom Dateisystem nicht unterstützt: " + e.getMessage();
LOG.warn("Prompt speichern fehlgeschlagen (kein Fallback): {}", message, e);
return new PromptSaveResult.AtomicMoveFailed(message);
} catch (IOException e) {
beräumeTempDatei(tempFile);
String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
LOG.warn(SAVE_FAILED_LOG_MSG, message, e);
return new PromptSaveResult.AtomicMoveFailed(message);
}
}
/**
* Leitet den stabilen Prompt-Identifikator aus dem Dateinamen ab.
* <p>
* Der Identifikator entspricht dem Dateinamen ohne Verzeichnispfad.
*
* @return stabiler {@link PromptIdentifier} basierend auf dem Dateinamen
*/
private PromptIdentifier deriveIdentifier() {
String filename = promptFilePath.getFileName().toString();
return new PromptIdentifier(filename);
}
/**
* Versucht, die temporäre Datei zu löschen. Fehler werden nur geloggt.
*
* @param tempFile die zu löschende temporäre Datei
*/
private void beräumeTempDatei(Path tempFile) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ex) {
LOG.warn("Temporäre Prompt-Datei konnte nicht gelöscht werden: {}", tempFile, ex);
}
}
}
@@ -43,6 +43,8 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceC
* Ausnahmen an den Aufrufer weitergegeben.
*/
public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
private static final String INVALID_PATH_PREFIX = "Ungültiger Pfad: ";
private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class);
@@ -66,7 +68,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
Path path = toPath(suggestion.path());
if (path == null) {
String msg = "Ungültiger Pfad: " + suggestion.path();
String msg = INVALID_PATH_PREFIX + suggestion.path();
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg);
}
@@ -114,7 +116,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
Path path = toPath(suggestion.path());
if (path == null) {
String msg = "Ungültiger Pfad: " + suggestion.path();
String msg = INVALID_PATH_PREFIX + suggestion.path();
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg);
}
@@ -164,7 +166,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
Path path = toPath(suggestion.path());
if (path == null) {
String msg = "Ungültiger Pfad: " + suggestion.path();
String msg = INVALID_PATH_PREFIX + suggestion.path();
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg);
}
@@ -0,0 +1,24 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
/**
* Technischer Fehler im SQLite-Adapter fuer Modell-Preise.
*
* <p>Wird vom {@link SqliteModelPriceRepositoryAdapter} geworfen, wenn ein
* JDBC-Fehler beim Lesen, Schreiben oder Loeschen aufgetreten ist. Die
* Application-Schicht und die GUI behandeln diese Exception als
* technischen Fehler mit deutscher Meldung.
*/
public class ModelPriceRepositoryException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Erzeugt eine neue Ausnahme mit Meldung und Ursache.
*
* @param message deutsche Meldung
* @param cause urspruenglicher Fehler
*/
public ModelPriceRepositoryException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,81 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
/**
* Zentrale Factory fuer SQLite-Connections.
*
* <p>Wird von allen Repository-Adaptern und vom UnitOfWork-Adapter genutzt,
* um Connections mit einheitlichen PRAGMA-Einstellungen zu oeffnen. Damit
* sind WAL-Modus, {@code busy_timeout} und {@code foreign_keys} fuer alle
* Schreib- und Lesepfade wirksam.
*
* <p>Folgende PRAGMAs werden auf jeder Connection gesetzt:
* <ul>
* <li>{@code PRAGMA journal_mode=WAL} Reader werden nicht durch Writer blockiert.</li>
* <li>{@code PRAGMA busy_timeout=5000} wartet bis zu 5 Sekunden, bevor
* {@code SQLITE_BUSY} geworfen wird.</li>
* <li>{@code PRAGMA foreign_keys=ON} aktiviert die Pruefung von Fremdschluesseln
* (entspricht dem bestehenden Verhalten der bisherigen Adapter).</li>
* </ul>
*
* <p>Die Factory ist stateless; die Methode {@link #open(String)} liefert
* jeweils eine frische {@link Connection}, die der Aufrufer (typisch via
* try-with-resources) wieder schlie&szlig;t.
*/
public final class SqliteConnectionFactory {
private SqliteConnectionFactory() {
// Utility class
}
/**
* Oeffnet eine neue SQLite-Connection und setzt die Standard-PRAGMAs.
*
* @param jdbcUrl JDBC-URL zur SQLite-Datenbank, z.B. {@code jdbc:sqlite:/pfad/db.sqlite}
* @return eine neue, eingerichtete {@link Connection}
* @throws SQLException wenn die Verbindung nicht hergestellt oder die
* PRAGMAs nicht gesetzt werden koennen
*/
public static Connection open(String jdbcUrl) throws SQLException {
Connection connection = DriverManager.getConnection(jdbcUrl);
try {
applyDefaultPragmas(connection);
} catch (SQLException ex) {
try {
connection.close();
} catch (SQLException ignored) {
// Schliessfehler maskieren den eigentlichen Setup-Fehler nicht
}
throw ex;
}
return connection;
}
/**
* Setzt die Standard-PRAGMAs auf einer bereits geoeffneten Connection.
*
* <p>Die Foreign-Key-Pruefung wird hier <strong>nicht</strong> aktiviert,
* um das bisher faktisch praktizierte Verhalten von Repository-Connections
* zu erhalten. Die Foreign-Key-Pruefung wird durch
* {@code SqliteSchemaInitializationAdapter} auf der zentralen DataSource
* fuer Schema-Operationen explizit aktiviert; einzelne Repository-
* Connections, die ueber diese Factory geoeffnet werden, behalten das
* bisherige Verhalten der direkten {@code DriverManager.getConnection}-
* Aufrufe und setzen Foreign-Keys nicht implizit.
*
* @param connection bestehende Connection; nicht {@code null}
* @throws SQLException wenn ein PRAGMA-Statement fehlschlaegt
*/
private static void applyDefaultPragmas(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
// WAL: Reader werden nicht durch Writer blockiert.
statement.execute("PRAGMA journal_mode=WAL");
// 5 Sekunden Wartezeit pro Connection bei SQLITE_BUSY.
statement.execute("PRAGMA busy_timeout=5000");
}
}
}
@@ -0,0 +1,199 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.flywaydb.core.Flyway;
import org.sqlite.SQLiteConfig;
import org.sqlite.SQLiteDataSource;
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
/**
* SQLite-Implementierung des {@link DatabaseCreationPort}.
* <p>
* Erzeugt eine neue, leere SQLite-Datenbank gegen einen vom Aufrufer übergebenen
* temporären Zielpfad und führt eine vollständige Flyway-Migration auf den neuesten
* Schema-Stand aus. Anschließend wird ein Verbindungstest durchgeführt, der drei
* Aspekte verifiziert:
* <ol>
* <li>Eine SQLite-Verbindung kann erfolgreich geöffnet werden.</li>
* <li>Die Flyway-History-Tabelle (Standardname {@code flyway_schema_history}) ist
* vorhanden und enthält mindestens einen erfolgreichen Migrationseintrag.</li>
* <li>Eine einfache Leseabfrage gegen Schema-Metadaten
* ({@code sqlite_master}) liefert ohne Fehler.</li>
* </ol>
* <p>
* Im Fehlerfall wird die temporäre Datei zuverlässig wieder entfernt; aufrufende
* Komponenten erhalten ein klassifiziertes
* {@link DatabaseCreationPort.DatabaseCreationResult.Failure}-Ergebnis.
*
* <h2>Architekturgrenze</h2>
* <p>JDBC, SQLite-Konfiguration und Flyway-spezifische Typen verbleiben vollständig in
* dieser Klasse. Nach außen wird ausschließlich der versiegelte Port-Ergebnistyp
* herausgereicht.
*/
public class SqliteDatabaseCreationAdapter implements DatabaseCreationPort {
private static final Logger LOG = LogManager.getLogger(SqliteDatabaseCreationAdapter.class);
private static final String FLYWAY_HISTORY_TABLE = "flyway_schema_history";
/**
* Standardkonstruktor.
*/
public SqliteDatabaseCreationAdapter() {
// keine Felder, kein Zustand
}
/**
* Legt eine neue, leere SQLite-Datenbank an, migriert sie auf den neuesten Stand
* und führt einen Verbindungstest durch. Bei Fehlern wird die Temp-Datei entfernt.
*
* @param tempFile Pfad der zu erzeugenden temporären SQLite-Datei; darf nicht
* {@code null} sein und sollte vor dem Aufruf nicht existieren
* @return {@link DatabaseCreationResult.Success} bei Erfolg oder
* {@link DatabaseCreationResult.Failure} mit klassifizierter Phase
*/
@Override
public DatabaseCreationResult createAndInitialize(Path tempFile) {
if (tempFile == null) {
throw new NullPointerException("tempFile darf nicht null sein");
}
Path absoluteTemp = tempFile.toAbsolutePath().normalize();
LOG.info("Lege neue temporäre SQLite-Datenbank an: {}", absoluteTemp);
// Verhindern, dass eine versehentlich vorhandene Temp-Datei mitmigiert wird
try {
if (Files.exists(absoluteTemp)) {
Files.delete(absoluteTemp);
}
} catch (IOException e) {
LOG.error("Vorhandene temporäre Datei konnte nicht entfernt werden: {}",
absoluteTemp, e);
return new DatabaseCreationResult.Failure(
DatabaseCreationResult.Phase.FILE_CREATION,
"Vorhandene temporäre Datei konnte nicht entfernt werden: " + e.getMessage(),
e);
}
String jdbcUrl = buildJdbcUrl(absoluteTemp);
DataSource dataSource = createDataSource(jdbcUrl);
// Schema-Migration auf neuesten Stand
try {
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.connectRetries(0)
.load();
flyway.migrate();
LOG.info("Flyway-Migration auf neuesten Stand abgeschlossen für: {}", absoluteTemp);
} catch (RuntimeException e) {
LOG.error("Flyway-Migration fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
cleanup(absoluteTemp);
return new DatabaseCreationResult.Failure(
DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
"Schema-Migration fehlgeschlagen: " + e.getMessage(),
e);
}
// Verbindungstest gegen die migrierte Temp-Datei
try {
verifyConnection(dataSource);
LOG.info("Verbindungstest gegen neue SQLite-Datenbank erfolgreich: {}", absoluteTemp);
} catch (SQLException | IllegalStateException e) {
LOG.error("Verbindungstest fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
cleanup(absoluteTemp);
return new DatabaseCreationResult.Failure(
DatabaseCreationResult.Phase.CONNECTION_TEST,
"Verbindungstest fehlgeschlagen: " + e.getMessage(),
e);
}
return new DatabaseCreationResult.Success(absoluteTemp);
}
/**
* Verifiziert die migrierte Datenbank durch drei aufeinander aufbauende Prüfungen.
*
* @param dataSource die DataSource gegen die Temp-Datei
* @throws SQLException bei JDBC-Fehlern
* @throws IllegalStateException wenn eine fachliche Erwartung (z. B. Flyway-History
* vorhanden, mind. ein erfolgreicher Eintrag) verletzt ist
*/
private void verifyConnection(DataSource dataSource) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
try (Statement stmt = conn.createStatement()) {
try (ResultSet rs = stmt.executeQuery(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='"
+ FLYWAY_HISTORY_TABLE + "'")) {
if (!rs.next() || rs.getInt(1) != 1) {
throw new IllegalStateException(
"Flyway-History-Tabelle fehlt nach der Migration.");
}
}
try (ResultSet rs = stmt.executeQuery(
"SELECT count(*) FROM " + FLYWAY_HISTORY_TABLE + " WHERE success = 1")) {
if (!rs.next() || rs.getInt(1) < 1) {
throw new IllegalStateException(
"Flyway-History enthält keinen erfolgreichen Migrationseintrag.");
}
}
// einfache Leseabfrage gegen Schema-Metadaten
try (ResultSet rs = stmt.executeQuery(
"SELECT name FROM sqlite_master WHERE type='table'")) {
int tableCount = 0;
while (rs.next()) {
tableCount++;
}
if (tableCount < 1) {
throw new IllegalStateException(
"Schema-Metadatenabfrage lieferte keine Tabellen.");
}
}
}
}
}
private void cleanup(Path tempFile) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException e) {
LOG.warn("Temporäre SQLite-Datei konnte nach Fehler nicht entfernt werden: {} {}",
tempFile, e.getMessage());
}
}
/**
* Baut die JDBC-URL für eine SQLite-Datei nach dem im Projekt etablierten Schema.
*
* @param dbFile absoluter Pfad der SQLite-Datei; darf nicht {@code null} sein
* @return die JDBC-URL in der Form {@code jdbc:sqlite:/pfad/zur/datei.db}
*/
private static String buildJdbcUrl(Path dbFile) {
return "jdbc:sqlite:" + dbFile.toAbsolutePath().toString().replace('\\', '/');
}
/**
* Erstellt eine SQLite-DataSource mit aktivierten Fremdschlüsseln.
*
* @param jdbcUrl die JDBC-URL der SQLite-Datei
* @return eine konfigurierte {@link DataSource}; nie {@code null}
*/
private static DataSource createDataSource(String jdbcUrl) {
SQLiteConfig config = new SQLiteConfig();
config.enforceForeignKeys(true);
SQLiteDataSource ds = new SQLiteDataSource(config);
ds.setUrl(jdbcUrl);
return ds;
}
}
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -373,6 +372,6 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
* @throws SQLException if the connection cannot be established
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
return SqliteConnectionFactory.open(jdbcUrl);
}
}
@@ -0,0 +1,427 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
* SQLite-Implementierung von {@link HistoryQueryPort}.
* <p>
* Kapselt alle lesenden Datenbankoperationen für den Historien-Tab.
* Sämtliche JDBC-Details sind strikt in dieser Klasse eingeschlossen;
* keine JDBC-Typen erscheinen im Port-Interface oder in Domänen-/Application-Typen.
* <p>
* <strong>Suche:</strong> Freitextsuche ist case-insensitiv (via {@code LOWER()}).
* Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem
* SQL-LIKE-Aufruf mit {@code \} escaped.
* <p>
* <strong>Sortierung:</strong> Standard absteigend nach {@code updated_at},
* Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar).
* <p>
* <strong>Limit:</strong> Wird direkt als SQL-{@code LIMIT} angewendet.
* Ein Limit von 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr
* als 500 Treffer vorhanden sind.
*/
public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
private static final Logger logger = LogManager.getLogger(SqliteHistoryQueryAdapter.class);
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
private final String jdbcUrl;
/**
* Erzeugt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
*
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
* @throws NullPointerException wenn {@code jdbcUrl} null ist
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
*/
public SqliteHistoryQueryAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
}
this.jdbcUrl = jdbcUrl;
}
/**
* {@inheritDoc}
* <p>
* Die SQL-Abfrage aggregiert die Versuchsanzahl per {@code COUNT}-Subquery.
* Freitextsuche und Status-Filter werden als optionale WHERE-Klauseln ergänzt.
*
* @param query Abfrageparameter; darf nicht {@code null} sein
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
Objects.requireNonNull(query, "query darf nicht null sein");
StringBuilder sql = new StringBuilder("""
SELECT
dr.fingerprint,
dr.overall_status,
dr.last_known_source_file_name,
dr.last_target_file_name,
dr.last_known_source_locator,
dr.updated_at,
(SELECT COUNT(*) FROM processing_attempt pa WHERE pa.fingerprint = dr.fingerprint) AS attempt_count
FROM document_record dr
WHERE 1=1
""");
List<Object> params = new ArrayList<>();
// Freitextsuche: case-insensitiv über Quelldateiname und Zieldateiname
String searchText = query.searchText();
if (searchText != null && !searchText.isBlank()) {
String escaped = escapeSqlLike(searchText.strip().toLowerCase());
sql.append(" AND (LOWER(dr.last_known_source_file_name) LIKE ? ESCAPE '\\' "
+ "OR LOWER(dr.last_target_file_name) LIKE ? ESCAPE '\\')");
String pattern = "%" + escaped + "%";
params.add(pattern);
params.add(pattern);
}
// Status-Filter
String statusFilter = query.statusFilter();
if (statusFilter != null && !statusFilter.isBlank()) {
sql.append(" AND dr.overall_status = ?");
params.add(statusFilter.strip());
}
sql.append(" ORDER BY dr.updated_at DESC, dr.fingerprint ASC");
sql.append(" LIMIT ?");
params.add(query.limit());
try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement();
PreparedStatement stmt = connection.prepareStatement(sql.toString())) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
for (int i = 0; i < params.size(); i++) {
stmt.setObject(i + 1, params.get(i));
}
try (ResultSet rs = stmt.executeQuery()) {
List<DocumentHistoryRow> rows = new ArrayList<>();
while (rs.next()) {
rows.add(mapToDocumentHistoryRow(rs));
}
logger.debug("Historien-Übersicht geladen: {} Zeilen (Limit {})", rows.size(), query.limit());
return List.copyOf(rows);
}
} catch (SQLException e) {
String message = "Historien-Übersicht konnte nicht geladen werden: " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* {@inheritDoc}
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
String sql = """
SELECT
last_known_source_locator,
last_known_source_file_name,
overall_status,
content_error_count,
transient_error_count,
last_failure_instant,
last_success_instant,
created_at,
updated_at,
last_target_path,
last_target_file_name
FROM document_record
WHERE fingerprint = ?
""";
try (Connection connection = getConnection();
PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapToDocumentRecord(rs, fingerprint));
}
return Optional.empty();
}
} catch (SQLException e) {
String message = "Dokument-Stammsatz konnte nicht geladen werden für Fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* {@inheritDoc}
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return unveränderliche Liste der Versuche aufsteigend nach {@code attempt_number};
* nie {@code null}; kann leer sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
String sql = """
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
WHERE fingerprint = ?
ORDER BY attempt_number ASC
""";
try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement();
PreparedStatement stmt = connection.prepareStatement(sql)) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
stmt.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = stmt.executeQuery()) {
List<ProcessingAttempt> attempts = new ArrayList<>();
while (rs.next()) {
attempts.add(mapToProcessingAttempt(rs));
}
return List.copyOf(attempts);
}
} catch (SQLException e) {
String message = "Verarbeitungsversuche konnten nicht geladen werden für Fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
// -------------------------------------------------------------------------
// Mapping-Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Bildet eine ResultSet-Zeile auf eine {@link DocumentHistoryRow} ab.
*
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
* @return die gemappte Zeile; nie {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private DocumentHistoryRow mapToDocumentHistoryRow(ResultSet rs) throws SQLException {
String fpHex = rs.getString("fingerprint");
String statusStr = rs.getString("overall_status");
String sourceFileName = rs.getString("last_known_source_file_name");
String targetFileName = rs.getString("last_target_file_name"); // nullable
String sourcePath = rs.getString("last_known_source_locator");
String updatedAtStr = rs.getString("updated_at");
long attemptCount = rs.getLong("attempt_count");
return new DocumentHistoryRow(
new DocumentFingerprint(fpHex),
ProcessingStatus.valueOf(statusStr),
sourceFileName,
targetFileName,
sourcePath,
stringToInstant(updatedAtStr),
attemptCount);
}
/**
* Bildet eine ResultSet-Zeile auf einen {@link DocumentRecord} ab.
*
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
* @param fingerprint der Fingerprint, der bereits bekannt ist
* @return der gemappte Stammsatz; nie {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private DocumentRecord mapToDocumentRecord(ResultSet rs, DocumentFingerprint fingerprint) throws SQLException {
return new DocumentRecord(
fingerprint,
new SourceDocumentLocator(rs.getString("last_known_source_locator")),
rs.getString("last_known_source_file_name"),
ProcessingStatus.valueOf(rs.getString("overall_status")),
new FailureCounters(
rs.getInt("content_error_count"),
rs.getInt("transient_error_count")),
stringToInstant(rs.getString("last_failure_instant")),
stringToInstant(rs.getString("last_success_instant")),
stringToInstant(rs.getString("created_at")),
stringToInstant(rs.getString("updated_at")),
rs.getString("last_target_path"),
rs.getString("last_target_file_name"));
}
/**
* Bildet eine ResultSet-Zeile auf einen {@link ProcessingAttempt} ab.
*
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
* @return der gemappte Versuch; nie {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private ProcessingAttempt mapToProcessingAttempt(ResultSet rs) throws SQLException {
String resolvedDateStr = rs.getString("resolved_date");
LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;
String dateSourceStr = rs.getString("date_source");
DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;
int processedPageCountRaw = rs.getInt("processed_page_count");
Integer processedPageCount = rs.wasNull() ? null : processedPageCountRaw;
int sentCharacterCountRaw = rs.getInt("sent_character_count");
Integer sentCharacterCount = rs.wasNull() ? null : sentCharacterCountRaw;
return new ProcessingAttempt(
new DocumentFingerprint(rs.getString("fingerprint")),
new RunId(rs.getString("run_id")),
rs.getInt("attempt_number"),
stringToInstant(rs.getString("started_at")),
stringToInstant(rs.getString("ended_at")),
ProcessingStatus.valueOf(rs.getString("status")),
rs.getString("failure_class"),
rs.getString("failure_message"),
rs.getBoolean("retryable"),
rs.getString("ai_provider"),
rs.getString("model_name"),
rs.getString("prompt_identifier"),
processedPageCount,
sentCharacterCount,
rs.getString("ai_raw_response"),
rs.getString("ai_reasoning"),
resolvedDate,
dateSource,
rs.getString("validated_title"),
rs.getString("final_target_file_name"),
readNullableLong(rs, "input_tokens"),
readNullableLong(rs, "output_tokens"),
readNullableLong(rs, "cache_creation_input_tokens"),
readNullableLong(rs, "cache_read_input_tokens"),
readNullableLong(rs, "price_input_per_token_nano_usd"),
readNullableLong(rs, "price_output_per_token_nano_usd"));
}
/**
* Liest einen nullable {@link Long}-Wert aus einer Spalte.
*
* @param rs das ResultSet
* @param columnName Spaltenname
* @return Wert oder {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private static Long readNullableLong(ResultSet rs, String columnName) throws SQLException {
long value = rs.getLong(columnName);
return rs.wasNull() ? null : value;
}
// -------------------------------------------------------------------------
// SQL-LIKE Escaping
// -------------------------------------------------------------------------
/**
* Escaped Sonderzeichen {@code %} und {@code _} in einer LIKE-Eingabe mit {@code \}.
* <p>
* Der Escape-Charakter {@code \} muss in der SQL-Abfrage als
* {@code ESCAPE '\'} angegeben werden.
*
* @param input die rohe Benutzereingabe; darf nicht {@code null} sein
* @return der escaped String; nie {@code null}
*/
private static String escapeSqlLike(String input) {
return input
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
}
// -------------------------------------------------------------------------
// JDBC-Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Öffnet eine neue Datenbankverbindung zur konfigurierten SQLite-Datei.
* <p>
* Kann in Unterklassen überschrieben werden, um eine gemeinsam genutzte
* Transaktions-Verbindung bereitzustellen.
*
* @return eine neue Datenbankverbindung
* @throws SQLException wenn die Verbindung nicht hergestellt werden kann
*/
protected Connection getConnection() throws SQLException {
return SqliteConnectionFactory.open(jdbcUrl);
}
/**
* Parst einen Instant aus einer String-Darstellung.
* <p>
* Unterstützt ISO-8601 (modern) und das Legacy-Format {@code yyyy-MM-dd HH:mm:ss} (UTC).
*
* @param stringValue die String-Darstellung; kann {@code null} sein
* @return das geparste Instant, oder {@code null} wenn die Eingabe leer oder nicht parsbar ist
*/
private Instant stringToInstant(String stringValue) {
if (stringValue == null || stringValue.isBlank()) {
return null;
}
try {
return Instant.parse(stringValue);
} catch (Exception e) {
try {
LocalDateTime dateTime = LocalDateTime.parse(stringValue,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return dateTime.atZone(ZoneId.of("UTC")).toInstant();
} catch (Exception fallback) {
logger.warn("Instant konnte nicht geparst werden '{}': {}", stringValue, fallback.getMessage());
return null;
}
}
}
}
@@ -0,0 +1,259 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository;
/**
* SQLite-Implementierung des {@link ModelPriceRepository}.
*
* <p>Persistiert Modell-Preise in der Tabelle {@code model_price}. Inserts und
* Updates erfolgen via {@code INSERT ... ON CONFLICT(provider, model_name)
* DO UPDATE SET ...}; Loeschungen via direktes {@code DELETE}. Der
* {@link #saveAllChanges(ModelPriceChangeSet) Batch-Pfad} faehrt eine
* JDBC-Transaktion mit {@code autoCommit=false}, ROLLBACKt bei Fehlern und
* COMMITet bei Erfolg.
*
* <p>Beim Lesen wird der DB-String {@code updated_at} als {@link Instant}
* geparst. Schlaegt das Parsing fehl, liefert die Adapter-Methode einen
* {@link ModelPriceView} mit {@code updatedAt=null} und
* {@code invalidUpdatedAt=true}; der Originalstring landet in
* {@code invalidUpdatedAtRaw}, damit GUI/CLI &quot;ung&uuml;ltig&quot; anzeigen
* koennen.
*/
public class SqliteModelPriceRepositoryAdapter implements ModelPriceRepository {
private static final Logger LOG = LogManager.getLogger(SqliteModelPriceRepositoryAdapter.class);
private static final String SQL_FIND_ALL = """
SELECT provider, model_name,
price_input_per_token_nano_usd, price_output_per_token_nano_usd,
currency, updated_at
FROM model_price
ORDER BY provider, model_name
""";
private static final String SQL_FIND_BY_KEY = """
SELECT provider, model_name,
price_input_per_token_nano_usd, price_output_per_token_nano_usd,
currency, updated_at
FROM model_price
WHERE provider = ? AND model_name = ?
""";
private static final String SQL_UPSERT = """
INSERT INTO model_price
(provider, model_name,
price_input_per_token_nano_usd, price_output_per_token_nano_usd,
currency, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(provider, model_name) DO UPDATE SET
price_input_per_token_nano_usd = excluded.price_input_per_token_nano_usd,
price_output_per_token_nano_usd = excluded.price_output_per_token_nano_usd,
currency = excluded.currency,
updated_at = excluded.updated_at
""";
private static final String SQL_DELETE = """
DELETE FROM model_price WHERE provider = ? AND model_name = ?
""";
private final String jdbcUrl;
/**
* Erzeugt den Adapter mit der JDBC-URL der Ziel-Datenbank.
*
* @param jdbcUrl JDBC-URL der SQLite-Datenbank; weder {@code null} noch leer
*/
public SqliteModelPriceRepositoryAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
}
this.jdbcUrl = jdbcUrl;
}
/**
* Oeffnet eine neue Connection ueber die zentrale Connection-Factory.
*
* @return eingerichtete Connection
* @throws SQLException wenn der Verbindungsaufbau fehlschlaegt
*/
protected Connection getConnection() throws SQLException {
return SqliteConnectionFactory.open(jdbcUrl);
}
@Override
public List<ModelPriceView> findAll() {
List<ModelPriceView> result = new ArrayList<>();
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_FIND_ALL);
ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
result.add(mapRow(rs));
}
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preise konnten nicht gelesen werden: " + e.getMessage(), e);
}
return List.copyOf(result);
}
@Override
public Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(modelName, "modelName");
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_FIND_BY_KEY)) {
statement.setString(1, provider);
statement.setString(2, modelName);
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
return Optional.empty();
}
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preis-Lookup fehlgeschlagen fuer (" + provider + ", " + modelName
+ "): " + e.getMessage(), e);
}
}
@Override
public void upsert(ModelPriceEntry entry) {
Objects.requireNonNull(entry, "entry");
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_UPSERT)) {
bindUpsert(statement, entry);
statement.executeUpdate();
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preis-Upsert fehlgeschlagen: " + e.getMessage(), e);
}
}
@Override
public void delete(String provider, String modelName) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(modelName, "modelName");
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_DELETE)) {
statement.setString(1, provider);
statement.setString(2, modelName);
statement.executeUpdate();
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preis-Delete fehlgeschlagen: " + e.getMessage(), e);
}
}
@Override
public void saveAllChanges(ModelPriceChangeSet changeSet) {
Objects.requireNonNull(changeSet, "changeSet");
if (changeSet.isEmpty()) {
return;
}
try (Connection connection = getConnection()) {
connection.setAutoCommit(false);
try {
if (!changeSet.upserts().isEmpty()) {
try (PreparedStatement upsertStmt = connection.prepareStatement(SQL_UPSERT)) {
for (ModelPriceEntry entry : changeSet.upserts()) {
bindUpsert(upsertStmt, entry);
upsertStmt.executeUpdate();
}
}
}
if (!changeSet.deletions().isEmpty()) {
try (PreparedStatement deleteStmt = connection.prepareStatement(SQL_DELETE)) {
for (ModelPriceKey key : changeSet.deletions()) {
deleteStmt.setString(1, key.provider());
deleteStmt.setString(2, key.modelName());
deleteStmt.executeUpdate();
}
}
}
connection.commit();
} catch (SQLException txError) {
try {
connection.rollback();
} catch (SQLException rollbackError) {
LOG.error("Rollback nach Modell-Preis-Batch-Fehler ebenfalls fehlgeschlagen: {}",
rollbackError.getMessage(), rollbackError);
}
throw new ModelPriceRepositoryException(
"Modell-Preis-Batch konnte nicht persistiert werden: " + txError.getMessage(),
txError);
}
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Datenbankverbindung fuer Modell-Preis-Batch fehlgeschlagen: " + e.getMessage(), e);
}
}
private static void bindUpsert(PreparedStatement statement, ModelPriceEntry entry) throws SQLException {
statement.setString(1, entry.provider());
statement.setString(2, entry.modelName());
statement.setLong(3, entry.priceInputPerTokenNanoUsd());
statement.setLong(4, entry.priceOutputPerTokenNanoUsd());
statement.setString(5, entry.currency());
statement.setString(6, entry.updatedAt().toString());
}
/**
* Liest eine Zeile in einen {@link ModelPriceView}.
*
* <p>Bei nicht parsebarem {@code updated_at} wird ein WARN-Log erzeugt
* und der View mit {@code updatedAt=null}, {@code invalidUpdatedAt=true}
* sowie dem Originalstring zurueckgegeben.
*
* @param rs aktueller ResultSet, dessen Cursor auf der Zielzeile steht
* @return Lesen-DTO
* @throws SQLException bei JDBC-Fehlern
*/
private static ModelPriceView mapRow(ResultSet rs) throws SQLException {
String provider = rs.getString("provider");
String modelName = rs.getString("model_name");
long priceIn = rs.getLong("price_input_per_token_nano_usd");
long priceOut = rs.getLong("price_output_per_token_nano_usd");
String currency = rs.getString("currency");
String updatedAtRaw = rs.getString("updated_at");
Instant updatedAt = null;
boolean invalid = false;
String invalidRaw = null;
try {
updatedAt = Instant.parse(updatedAtRaw);
} catch (DateTimeParseException ex) {
LOG.warn("updated_at konnte fuer (Provider={}, Modell={}) nicht geparst werden: \"{}\"",
provider, modelName, updatedAtRaw);
invalid = true;
invalidRaw = updatedAtRaw;
} catch (NullPointerException ex) {
LOG.warn("updated_at war fuer (Provider={}, Modell={}) NULL als ungueltig markiert",
provider, modelName);
invalid = true;
invalidRaw = null;
}
return new ModelPriceView(provider, modelName, priceIn, priceOut, currency, updatedAt,
invalidRaw, invalid);
}
}
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -43,6 +42,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* application/domain type.
*/
public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttemptRepository {
private static final String FINGERPRINT_NOT_NULL = "fingerprint must not be null";
private static final Logger logger = LogManager.getLogger(SqliteProcessingAttemptRepositoryAdapter.class);
@@ -78,7 +79,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/
@Override
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
String sql = """
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
@@ -142,8 +143,14 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
resolved_date,
date_source,
validated_title,
final_target_file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
final_target_file_name,
input_tokens,
output_tokens,
cache_creation_input_tokens,
cache_read_input_tokens,
price_input_per_token_nano_usd,
price_output_per_token_nano_usd
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = getConnection();
@@ -159,7 +166,8 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
statement.setString(5, attempt.endedAt().toString());
statement.setString(6, attempt.status().name());
setNullableString(statement, 7, attempt.failureClass());
setNullableString(statement, 8, attempt.failureMessage());
// 1000-Zeichen-Grenze erzwingen; längere Meldungen werden mit " markiert
setNullableString(statement, 8, truncateFailureMessage(attempt.failureMessage()));
statement.setBoolean(9, attempt.retryable());
// AI provider identifier and AI traceability fields
setNullableString(statement, 10, attempt.aiProvider());
@@ -175,6 +183,13 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
attempt.dateSource() != null ? attempt.dateSource().name() : null);
setNullableString(statement, 19, attempt.validatedTitle());
setNullableString(statement, 20, attempt.finalTargetFileName());
// Token- und Preis-Snapshot-Felder; alle nullable
setNullableLong(statement, 21, attempt.inputTokens());
setNullableLong(statement, 22, attempt.outputTokens());
setNullableLong(statement, 23, attempt.cacheCreationInputTokens());
setNullableLong(statement, 24, attempt.cacheReadInputTokens());
setNullableLong(statement, 25, attempt.priceInputPerTokenNanoUsd());
setNullableLong(statement, 26, attempt.priceOutputPerTokenNanoUsd());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
@@ -203,7 +218,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/
@Override
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
String sql = """
SELECT
@@ -254,7 +269,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/
@Override
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
String sql = """
SELECT
@@ -360,6 +375,27 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
}
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Kürzt eine Fehlermeldung auf maximal 1000 Zeichen vor der Persistierung.
* Längere Meldungen werden mit " markiert.
*
* @param message die ursprüngliche Fehlermeldung; kann {@code null} sein
* @return die (ggf. gekürzte) Meldung oder {@code null}
*/
private static String truncateFailureMessage(String message) {
if (message == null) {
return null;
}
if (message.length() <= 1000) {
return message;
}
return message.substring(0, 997) + "";
}
// -------------------------------------------------------------------------
// JDBC nullable helpers
// -------------------------------------------------------------------------
@@ -382,6 +418,23 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
}
}
/**
* Setzt einen nullable {@link Long}-Wert auf einem PreparedStatement.
*
* @param stmt das Statement
* @param index 1-basierter Parameter-Index
* @param value Wert oder {@code null}
* @throws SQLException bei JDBC-Fehlern
*/
private static void setNullableLong(PreparedStatement stmt, int index, Long value)
throws SQLException {
if (value == null) {
stmt.setNull(index, Types.BIGINT);
} else {
stmt.setLong(index, value);
}
}
private static Object getNullableInt(ResultSet rs, String column) throws SQLException {
int value = rs.getInt(column);
return rs.wasNull() ? null : value;
@@ -400,7 +453,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/
@Override
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?";
@@ -433,6 +486,6 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
* Returns a JDBC connection. May be overridden in tests to provide shared connections.
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
return SqliteConnectionFactory.open(jdbcUrl);
}
}
@@ -1,337 +1,597 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import javax.sql.DataSource;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.flywaydb.core.Flyway;
import org.sqlite.SQLiteConfig;
import org.sqlite.SQLiteDataSource;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort;
/**
* SQLite implementation of {@link PersistenceSchemaInitializationPort}.
* <p>
* Creates or verifies the two-level persistence schema in the configured SQLite
* database file, and performs a controlled schema evolution from an earlier schema
* version to the current one.
* Flyway-basierte Implementierung von {@link PersistenceSchemaInitializationPort}.
*
* <h2>Two-level schema</h2>
* <p>The schema consists of exactly two tables:
* <ol>
* <li><strong>{@code document_record}</strong> the document master record
* (Dokument-Stammsatz). One row per unique SHA-256 fingerprint.</li>
* <li><strong>{@code processing_attempt}</strong> the processing attempt history
* (Versuchshistorie). One row per historised processing attempt, referencing
* the master record via fingerprint.</li>
* </ol>
* <p>Erstellt oder verifiziert das Zwei-Ebenen-Persistenzschema in der konfigurierten
* SQLite-Datenbank und führt dabei eine differenzierte Startstrategie durch,
* die drei Fälle unterscheidet:
*
* <h2>Schema evolution</h2>
* <p>
* When upgrading from an earlier schema, this adapter uses idempotent
* {@code ALTER TABLE ... ADD COLUMN} statements for both tables. Columns that already
* exist are silently skipped, making the evolution safe to run on both fresh and existing
* databases. The current evolution adds:
* <ul>
* <li>AI-traceability columns to {@code processing_attempt}</li>
* <li>Target-copy columns ({@code last_target_path}, {@code last_target_file_name}) to
* {@code document_record}</li>
* <li>Target-copy column ({@code final_target_file_name}) to {@code processing_attempt}</li>
* <li>Provider-identifier column ({@code ai_provider}) to {@code processing_attempt};
* existing rows receive {@code NULL} as the default, which is the correct value for
* attempts recorded before provider tracking was introduced.</li>
* </ul>
* <h2>Fall 1 Leere Datenbank</h2>
* <p>Keine fachlichen Tabellen und keine Flyway-History-Tabelle vorhanden
* (bzw. Datei existiert noch nicht). Flyway führt {@code V1__initial_schema.sql}
* vollständig aus und legt das komplette Schema an.
*
* <h2>Legacy-state migration</h2>
* <p>
* Documents in an earlier positive intermediate state ({@code SUCCESS} recorded without
* a validated naming proposal) are idempotently migrated to {@code READY_FOR_AI} so that
* the AI naming pipeline processes them in the next run. Terminal negative states
* ({@code FAILED_RETRYABLE}, {@code FAILED_FINAL}, skip states) are left unchanged.
* <h2>Fall 2 Bestehende Datenbank ohne Flyway-History</h2>
* <p>Fachliche Tabellen sind vorhanden, aber die Flyway-History-Tabelle fehlt.
* Vor der Baseline-Eintralung wird eine vollständige Schema-Prüfung gegen das
* V1-Zielschema durchgeführt. Bei konformem Schema wird ein datiertes Backup der
* SQLite-Datei erstellt, und Flyway trägt nur eine Baseline ein (Skript wird
* <em>nicht</em> ausgeführt). Bei fehlendem Schema-Element bricht der Start mit
* einer klaren Fehlermeldung ab.
*
* <h2>Initialisation timing</h2>
* <p>This adapter must be invoked <em>once</em> at program startup, before the batch
* document processing loop begins.
* <h2>Fall 3 Folgestart mit Flyway-History</h2>
* <p>Flyway-History-Tabelle ist vorhanden. Flyway läuft idempotent und
* führt nur noch fehlende Migrationen aus.
*
* <h2>Architecture boundary</h2>
* <p>All JDBC connections, SQL DDL, and SQLite-specific behaviour are strictly confined
* to this class. No JDBC or SQLite types appear in the port interface or in any
* application/domain type.
* <h2>Fremdschlüssel</h2>
* <p>Foreign-Key-Durchsetzung wird über {@code SQLiteConfig.enforceForeignKeys(true)}
* auf DataSource-Ebene aktiviert, sodass jede neue Verbindung automatisch
* {@code PRAGMA foreign_keys = ON} erhält.
*
* <h2>Architekturgrenze</h2>
* <p>Alle JDBC-Verbindungen, SQL-DDL und SQLite-spezifisches Verhalten sind
* ausschließlich in dieser Klasse gekapselt. Im Port-Interface und in den
* Domain-/Application-Typen erscheinen keine JDBC- oder SQLite-Typen.
*/
public class SqliteSchemaInitializationAdapter implements PersistenceSchemaInitializationPort {
private static final String TABLE_DOCUMENT_RECORD = "document_record";
private static final String TABLE_PROCESSING_ATTEMPT = "processing_attempt";
private static final String COL_FINGERPRINT = "fingerprint";
private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class);
// -------------------------------------------------------------------------
// DDL document_record table
// Erwartete Tabellen und Spalten gemäß V1-Zielschema
// -------------------------------------------------------------------------
/** Alle erwarteten Spalten der Tabelle {@code document_record}. */
private static final Set<String> EXPECTED_COLUMNS_DOCUMENT_RECORD = Set.of(
"id", COL_FINGERPRINT, "last_known_source_locator", "last_known_source_file_name",
"overall_status", "content_error_count", "transient_error_count",
"last_failure_instant", "last_success_instant", "created_at", "updated_at",
"last_target_path", "last_target_file_name"
);
/**
* DDL for the document master record table.
* <p>
* Columns: id (PK), fingerprint (unique), last_known_source_locator,
* last_known_source_file_name, overall_status, content_error_count,
* transient_error_count, last_failure_instant, last_success_instant,
* created_at, updated_at.
* Alle erwarteten Spalten der Tabelle {@code processing_attempt} im
* V1-Zielschema.
*
* <p>Dies ist der minimale Zielzustand nach {@code V1__initial_schema}.
* Spaetere Migrationen (z.B. {@code V2__token_tracking}) ergaenzen
* additiv weitere Spalten; das Vorhandensein dieser zusaetzlichen Spalten
* vor dem Baseline-Eintrag ist <strong>kein</strong> Konformitaetskriterium,
* weil die Schema-Pruefung in Fall 2 ausschlie&szlig;lich gegen das
* V1-Schema arbeitet. Die V2-Spalten werden nach der Baseline-Eintragung
* durch Flyway ergaenzt.
*/
private static final String DDL_CREATE_DOCUMENT_RECORD = """
CREATE TABLE IF NOT EXISTS document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
last_known_source_locator TEXT NOT NULL,
last_known_source_file_name TEXT NOT NULL,
overall_status TEXT NOT NULL,
content_error_count INTEGER NOT NULL DEFAULT 0,
transient_error_count INTEGER NOT NULL DEFAULT 0,
last_failure_instant TEXT,
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
)
""";
// -------------------------------------------------------------------------
// DDL processing_attempt table (base schema, without AI traceability cols)
// -------------------------------------------------------------------------
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
"id", COL_FINGERPRINT, "run_id", "attempt_number", "started_at", "ended_at",
"status", "failure_class", "failure_message", "retryable",
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
"validated_title", "final_target_file_name", "ai_provider"
);
/**
* DDL for the base processing attempt history table.
* <p>
* Base columns (present in all schema versions): id, fingerprint, run_id,
* attempt_number, started_at, ended_at, status, failure_class, failure_message, retryable.
* <p>
* AI traceability columns are added separately via {@code ALTER TABLE} to support
* idempotent evolution from earlier schemas.
* Erwartete Indizes nach {@code V1__initial_schema}.
*
* <p>Spaetere Migrationen koennen additiv weitere Indizes anlegen; sie
* sind kein Konformitaetskriterium fuer Fall 2.
*/
private static final String DDL_CREATE_PROCESSING_ATTEMPT = """
CREATE TABLE IF NOT EXISTS processing_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
status TEXT NOT NULL,
failure_class TEXT,
failure_message TEXT,
retryable INTEGER NOT NULL DEFAULT 0,
CONSTRAINT fk_processing_attempt_fingerprint
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
CONSTRAINT uq_processing_attempt_fingerprint_number
UNIQUE (fingerprint, attempt_number)
)
""";
private static final Set<String> EXPECTED_INDEXES = Set.of(
"idx_processing_attempt_fingerprint",
"idx_processing_attempt_run_id",
"idx_document_record_overall_status"
);
/** Name der Flyway-History-Tabelle. */
private static final String FLYWAY_HISTORY_TABLE = "flyway_schema_history";
// -------------------------------------------------------------------------
// DDL indexes
// Felder
// -------------------------------------------------------------------------
/** Index on {@code processing_attempt.fingerprint} for fast per-document lookups. */
private static final String DDL_IDX_ATTEMPT_FINGERPRINT =
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint "
+ "ON processing_attempt (fingerprint)";
/** Index on {@code processing_attempt.run_id} for fast per-run lookups. */
private static final String DDL_IDX_ATTEMPT_RUN_ID =
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_run_id "
+ "ON processing_attempt (run_id)";
/** Index on {@code document_record.overall_status} for fast status-based filtering. */
private static final String DDL_IDX_RECORD_STATUS =
"CREATE INDEX IF NOT EXISTS idx_document_record_overall_status "
+ "ON document_record (overall_status)";
// -------------------------------------------------------------------------
// DDL columns added to processing_attempt via schema evolution
// -------------------------------------------------------------------------
/**
* Columns to add idempotently to {@code processing_attempt}.
* Each entry is {@code [column_name, column_type]}.
* <p>
* {@code ai_provider} is nullable; existing rows receive {@code NULL}, which is the
* correct sentinel for attempts recorded before provider tracking was introduced.
*/
private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = {
{"model_name", "TEXT"},
{"prompt_identifier", "TEXT"},
{"processed_page_count", "INTEGER"},
{"sent_character_count", "INTEGER"},
{"ai_raw_response", "TEXT"},
{"ai_reasoning", "TEXT"},
{"resolved_date", "TEXT"},
{"date_source", "TEXT"},
{"validated_title", "TEXT"},
{"final_target_file_name", "TEXT"},
{"ai_provider", "TEXT"},
};
// -------------------------------------------------------------------------
// DDL columns added to document_record via schema evolution
// -------------------------------------------------------------------------
/**
* Columns to add idempotently to {@code document_record}.
* Each entry is {@code [column_name, column_type]}.
*/
private static final String[][] EVOLUTION_RECORD_COLUMNS = {
{"last_target_path", "TEXT"},
{"last_target_file_name", "TEXT"},
};
// -------------------------------------------------------------------------
// Legacy-state status migration
// -------------------------------------------------------------------------
/**
* Migrates earlier positive intermediate states in {@code document_record} that were
* recorded as {@code SUCCESS} without a validated naming proposal to {@code READY_FOR_AI},
* so the AI naming pipeline processes them in the next run.
* <p>
* Only rows with {@code overall_status = 'SUCCESS'} that have no corresponding
* {@code processing_attempt} with {@code status = 'PROPOSAL_READY'} are updated.
* This migration is idempotent.
*/
private static final String SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI = """
UPDATE document_record
SET overall_status = 'READY_FOR_AI',
updated_at = datetime('now')
WHERE overall_status = 'SUCCESS'
AND NOT EXISTS (
SELECT 1 FROM processing_attempt pa
WHERE pa.fingerprint = document_record.fingerprint
AND pa.status = 'PROPOSAL_READY'
)
""";
private final String jdbcUrl;
/**
* Constructs the adapter with the JDBC URL of the SQLite database file.
* Erstellt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
*
* @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
* @throws NullPointerException if {@code jdbcUrl} is null
* @throws IllegalArgumentException if {@code jdbcUrl} is blank
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
* @throws NullPointerException wenn {@code jdbcUrl} {@code null} ist
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
*/
public SqliteSchemaInitializationAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null");
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl must not be blank");
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
}
this.jdbcUrl = jdbcUrl;
}
/**
* Creates or verifies the persistence schema and performs schema evolution and
* status migration.
* <p>
* Execution order:
* <ol>
* <li>Enable foreign key enforcement.</li>
* <li>Create {@code document_record} table (if not exists).</li>
* <li>Create {@code processing_attempt} table (if not exists).</li>
* <li>Create all indexes (if not exist).</li>
* <li>Add AI-traceability and provider-identifier columns to {@code processing_attempt}
* (idempotent evolution).</li>
* <li>Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).</li>
* </ol>
* <p>
* All steps are safe to run on both fresh and existing databases.
* Erstellt oder verifiziert das Persistenzschema per Flyway.
*
* @throws DocumentPersistenceException if any DDL or migration step fails
* <p>Erkennt anhand des Datenbankzustands automatisch einen der drei Fälle
* (leere DB, bestehende DB ohne Flyway-History, Folgestart mit Flyway-History)
* und wählt die passende Flyway-Konfiguration.
*
* @throws DocumentPersistenceException wenn das Schema nicht erstellt oder verifiziert
* werden kann, oder wenn die Schema-Prüfung bei
* einer bestehenden Datenbank fehlschlägt
*/
@Override
public void initializeSchema() {
logger.info("Initialising SQLite persistence schema at: {}", jdbcUrl);
try (Connection connection = DriverManager.getConnection(jdbcUrl);
Statement statement = connection.createStatement()) {
logger.info("Schema-Initialisierung gestartet für: {}", jdbcUrl);
try {
DataSource dataSource = createDataSource();
DbState state = determineDbState(dataSource);
logger.info("Erkannter Datenbankzustand: {}", state);
// Enable foreign key enforcement (SQLite disables it by default)
statement.execute("PRAGMA foreign_keys = ON");
// Level 1: document master record
statement.execute(DDL_CREATE_DOCUMENT_RECORD);
logger.debug("Table 'document_record' created or already present.");
// Level 2: processing attempt history (base columns only)
statement.execute(DDL_CREATE_PROCESSING_ATTEMPT);
logger.debug("Table 'processing_attempt' created or already present.");
// Indexes for efficient per-document, per-run, and per-status access
statement.execute(DDL_IDX_ATTEMPT_FINGERPRINT);
statement.execute(DDL_IDX_ATTEMPT_RUN_ID);
statement.execute(DDL_IDX_RECORD_STATUS);
logger.debug("Indexes created or already present.");
// Schema evolution: add AI-traceability + target-copy columns (idempotent)
evolveTableColumns(connection, "processing_attempt", EVOLUTION_ATTEMPT_COLUMNS);
evolveTableColumns(connection, "document_record", EVOLUTION_RECORD_COLUMNS);
// Status migration: earlier positive intermediate state READY_FOR_AI
int migrated = statement.executeUpdate(SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI);
if (migrated > 0) {
logger.info("Status migration: {} document(s) migrated from legacy SUCCESS state to READY_FOR_AI.",
migrated);
} else {
logger.debug("Status migration: no documents required migration.");
switch (state) {
case EMPTY -> runFall1NewDb(dataSource);
case EXISTING_WITHOUT_FLYWAY -> runFall2BaselineExistingDb(dataSource);
case FLYWAY_MANAGED -> runFall3FollowUpStart(dataSource);
}
logger.info("SQLite schema initialisation and migration completed successfully.");
} catch (SQLException e) {
String message = "Failed to initialise SQLite persistence schema at '" + jdbcUrl + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
logger.info("Schema-Initialisierung erfolgreich abgeschlossen.");
} catch (DocumentPersistenceException e) {
throw e;
} catch (Exception e) {
String msg = "Schema-Initialisierung fehlgeschlagen für '" + jdbcUrl + "': " + e.getMessage();
logger.error(msg, e);
throw new DocumentPersistenceException(msg, e);
}
}
/**
* Idempotently adds the given columns to the specified table.
* <p>
* For each column that does not yet exist, an {@code ALTER TABLE ... ADD COLUMN}
* statement is executed. Columns that already exist are silently skipped.
* Gibt die JDBC-URL zurück, die dieser Adapter verwendet.
*
* @param connection an open JDBC connection to the database
* @param tableName the name of the table to evolve
* @param columns array of {@code [column_name, column_type]} pairs to add
* @throws SQLException if a column addition fails for a reason other than duplicate column
*/
private void evolveTableColumns(Connection connection, String tableName, String[][] columns)
throws SQLException {
java.util.Set<String> existingColumns = new java.util.HashSet<>();
try (ResultSet rs = connection.getMetaData().getColumns(null, null, tableName, null)) {
while (rs.next()) {
existingColumns.add(rs.getString("COLUMN_NAME").toLowerCase());
}
}
for (String[] col : columns) {
String columnName = col[0];
String columnType = col[1];
if (!existingColumns.contains(columnName.toLowerCase())) {
String alterSql = "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnType;
try (Statement stmt = connection.createStatement()) {
stmt.execute(alterSql);
}
logger.debug("Schema evolution: added column '{}' to '{}'.", columnName, tableName);
} else {
logger.debug("Schema evolution: column '{}' in '{}' already present, skipped.",
columnName, tableName);
}
}
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
*
* @return the JDBC URL; never null or blank
* @return die JDBC-URL; niemals {@code null} oder leer
*/
public String getJdbcUrl() {
return jdbcUrl;
}
// -------------------------------------------------------------------------
// Fallbehandlung
// -------------------------------------------------------------------------
/**
* Fall 1: Leere Datenbank Flyway führt V1__initial_schema.sql vollständig aus.
*
* @param dataSource die konfigurierte DataSource
*/
private void runFall1NewDb(DataSource dataSource) {
logger.info("Fall 1: Leere Datenbank Flyway legt vollständiges Schema an.");
Flyway flyway = buildFlyway(dataSource, false);
flyway.migrate();
logger.info("Fall 1: Schema vollständig erstellt.");
}
/**
* Fall 2: Bestehende Datenbank ohne Flyway-History.
*
* <p>Führt die vollständige Schema-Prüfcheckliste durch. Bei konformem Schema
* wird ein datiertes Backup angelegt und Flyway trägt nur eine Baseline ein.
* Bei fehlendem Schema-Element bricht der Start ab.
*
* @param dataSource die konfigurierte DataSource
* @throws DocumentPersistenceException wenn das Schema nicht konform ist oder das Backup schlägt fehl
*/
private void runFall2BaselineExistingDb(DataSource dataSource) {
logger.info("Fall 2: Bestehende Datenbank ohne Flyway-History Schema-Prüfung läuft.");
// Vollständige Schema-Prüfung vor Baseline
try (Connection conn = dataSource.getConnection()) {
verifyExistingSchemaMatches(conn);
} catch (SQLException e) {
String msg = "Datenbankverbindung für Schema-Prüfung fehlgeschlagen: " + e.getMessage();
logger.error(msg, e);
throw new DocumentPersistenceException(msg, e);
}
logger.info("Fall 2: Schema-Prüfung bestanden.");
// Backup der SQLite-Datei anlegen
createDatedBackup();
// Flyway-Baseline eintragen (V1 wird NICHT ausgeführt)
Flyway flyway = buildFlyway(dataSource, true);
flyway.migrate();
logger.info("Fall 2: Flyway-Baseline erfolgreich eingetragen.");
}
/**
* Fall 3: Folgestart Flyway läuft idempotent und führt nur fehlende Migrationen aus.
*
* @param dataSource die konfigurierte DataSource
*/
private void runFall3FollowUpStart(DataSource dataSource) {
logger.info("Fall 3: Folgestart mit Flyway-History idempotente Migration.");
Flyway flyway = buildFlyway(dataSource, false);
flyway.migrate();
logger.info("Fall 3: Migration abgeschlossen (idempotent).");
}
/**
* Erzeugt eine standardisiert konfigurierte {@link Flyway}-Instanz.
*
* <p>Alle drei Fälle nutzen dieselbe Grundkonfiguration:
* <ul>
* <li>Explizite Migrations-Location {@code classpath:db/migration} verhindert
* unerwünschtes Klasspfad-Scannen des gesamten JARs.</li>
* <li>Keine Umgebungsvariablen-Konfiguration verhindert unbeabsichtigte
* Übersteuerung durch Build-System-Variablen.</li>
* <li>Kein Verbindungs-Retry ({@code connectRetries=0}) Fehler schlagen
* sofort statt nach mehreren Sekunden Wartezeit fehl.</li>
* </ul>
*
* @param dataSource die zu verwendende DataSource
* @param baselineOnMigrate ob beim Migrate eine Baseline einzutragen ist (nur Fall 2)
* @return eine konfigurierte, betriebsbereite {@link Flyway}-Instanz
*/
private Flyway buildFlyway(DataSource dataSource, boolean baselineOnMigrate) {
var config = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.connectRetries(0)
.baselineOnMigrate(baselineOnMigrate);
if (baselineOnMigrate) {
config = config
.baselineVersion("1")
.baselineDescription("Bestehende Datenbank baselined");
}
return config.load();
}
// -------------------------------------------------------------------------
// Datenbankzustand erkennen
// -------------------------------------------------------------------------
/**
* Repräsentiert den erkannten Zustand der SQLite-Datenbank beim Start.
*/
enum DbState {
/** Keine fachlichen Tabellen und keine Flyway-History vorhanden. */
EMPTY,
/** Fachliche Tabellen vorhanden, aber keine Flyway-History-Tabelle. */
EXISTING_WITHOUT_FLYWAY,
/** Flyway-History-Tabelle vorhanden Datenbank wird bereits von Flyway verwaltet. */
FLYWAY_MANAGED
}
/**
* Ermittelt den aktuellen Zustand der Datenbank.
*
* <p>"Leer" bedeutet: keine Tabellen vorhanden nicht nur Dateigröße 0 Byte.
*
* @param dataSource die zu prüfende DataSource
* @return der erkannte {@link DbState}
* @throws DocumentPersistenceException bei Verbindungsfehlern
*/
private DbState determineDbState(DataSource dataSource) {
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData meta = conn.getMetaData();
Set<String> tables = readTableNames(meta);
if (tables.contains(FLYWAY_HISTORY_TABLE)) {
return DbState.FLYWAY_MANAGED;
}
// "Leer" = keine Tabellen vorhanden (unabhängig von Dateigröße)
boolean hasFachlicheTabellen = tables.contains(TABLE_DOCUMENT_RECORD)
|| tables.contains(TABLE_PROCESSING_ATTEMPT);
if (hasFachlicheTabellen) {
return DbState.EXISTING_WITHOUT_FLYWAY;
}
return DbState.EMPTY;
} catch (SQLException e) {
String msg = "Datenbankzustand konnte nicht ermittelt werden: " + e.getMessage();
logger.error(msg, e);
throw new DocumentPersistenceException(msg, e);
}
}
// -------------------------------------------------------------------------
// Schema-Prüfcheckliste (Fall 2)
// -------------------------------------------------------------------------
/**
* Vollständige Schema-Prüfung gegen das V1-Zielschema.
*
* <p>Prüft alle erwarteten Tabellen, Spalten, Constraints und Indizes per
* {@link DatabaseMetaData}. Bei fehlendem Element wird der Start sofort mit
* einer aussagekräftigen Fehlermeldung abgebrochen kein stilles Heilen.
*
* @param conn offene JDBC-Verbindung zur Datenbank
* @throws DocumentPersistenceException wenn ein Schema-Element fehlt
* @throws SQLException bei technischen Datenbankfehlern
*/
private void verifyExistingSchemaMatches(Connection conn) throws SQLException {
DatabaseMetaData meta = conn.getMetaData();
List<String> fehler = new ArrayList<>();
// Tabellen prüfen
Set<String> tabellen = readTableNames(meta);
if (!tabellen.contains(TABLE_DOCUMENT_RECORD)) {
fehler.add("Tabelle 'document_record' fehlt");
}
if (!tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
fehler.add("Tabelle 'processing_attempt' fehlt");
}
// Spalten prüfen nur wenn Tabellen vorhanden
if (tabellen.contains(TABLE_DOCUMENT_RECORD)) {
pruefeSpaltenvollstaendigkeit(meta, TABLE_DOCUMENT_RECORD,
EXPECTED_COLUMNS_DOCUMENT_RECORD, fehler);
}
if (tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
pruefeSpaltenvollstaendigkeit(meta, TABLE_PROCESSING_ATTEMPT,
EXPECTED_COLUMNS_PROCESSING_ATTEMPT, fehler);
}
// Indizes prüfen
if (tabellen.contains(TABLE_DOCUMENT_RECORD) && tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
Set<String> vorhandeneIndizes = readIndexNames(meta);
for (String erwartetIndex : EXPECTED_INDEXES) {
if (!vorhandeneIndizes.contains(erwartetIndex)) {
fehler.add("Index '" + erwartetIndex + "' fehlt");
}
}
}
// Constraints prüfen (soweit per Metadata prüfbar)
if (tabellen.contains(TABLE_DOCUMENT_RECORD)) {
pruefeUniqueConstraintAufFingerprint(conn, fehler);
}
if (tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
pruefeForeignKeyAufDocumentRecord(conn, fehler);
}
if (!fehler.isEmpty()) {
String fehlerliste = String.join("; ", fehler);
String msg = "Schema-Prüfung fehlgeschlagen folgende Elemente fehlen oder sind nicht konform: "
+ fehlerliste;
logger.error(msg);
throw new DocumentPersistenceException(msg);
}
}
/**
* Prüft, ob alle erwarteten Spalten in der angegebenen Tabelle vorhanden sind.
*
* @param meta Datenbankmetadaten
* @param tabellenname Name der zu prüfenden Tabelle
* @param erwarteteSpalten Menge der erwarteten Spaltennamen (Kleinschreibung)
* @param fehler Liste, in die fehlende Elemente eingetragen werden
* @throws SQLException bei technischen Datenbankfehlern
*/
private void pruefeSpaltenvollstaendigkeit(DatabaseMetaData meta, String tabellenname,
Set<String> erwarteteSpalten, List<String> fehler) throws SQLException {
Set<String> vorhandeneSpalten = new HashSet<>();
try (ResultSet rs = meta.getColumns(null, null, tabellenname, null)) {
while (rs.next()) {
vorhandeneSpalten.add(rs.getString("COLUMN_NAME").toLowerCase());
}
}
for (String erwartet : erwarteteSpalten) {
if (!vorhandeneSpalten.contains(erwartet)) {
fehler.add("Spalte '" + tabellenname + "." + erwartet + "' fehlt");
}
}
}
/**
* Prüft das UNIQUE-Constraint auf {@code document_record.fingerprint} anhand der
* Indexmetadaten.
*
* @param conn offene JDBC-Verbindung
* @param fehler Liste, in die fehlende Elemente eingetragen werden
* @throws SQLException bei technischen Datenbankfehlern
*/
private void pruefeUniqueConstraintAufFingerprint(Connection conn,
List<String> fehler) throws SQLException {
boolean uniqueGefunden = false;
try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, TABLE_DOCUMENT_RECORD, true, false)) {
while (rs.next()) {
String spalte = rs.getString("COLUMN_NAME");
if (COL_FINGERPRINT.equalsIgnoreCase(spalte)) {
uniqueGefunden = true;
break;
}
}
}
if (!uniqueGefunden) {
fehler.add("UNIQUE-Constraint auf 'document_record.fingerprint' fehlt");
}
}
/**
* Prüft den Foreign Key von {@code processing_attempt.fingerprint} auf
* {@code document_record.fingerprint} anhand der Importschlüssel-Metadaten.
*
* @param conn offene JDBC-Verbindung
* @param fehler Liste, in die fehlende Elemente eingetragen werden
* @throws SQLException bei technischen Datenbankfehlern
*/
private void pruefeForeignKeyAufDocumentRecord(Connection conn,
List<String> fehler) throws SQLException {
boolean fkGefunden = false;
try (ResultSet rs = conn.getMetaData().getImportedKeys(null, null, TABLE_PROCESSING_ATTEMPT)) {
while (rs.next()) {
String pkTabelle = rs.getString("PKTABLE_NAME");
String fkSpalte = rs.getString("FKCOLUMN_NAME");
if (TABLE_DOCUMENT_RECORD.equalsIgnoreCase(pkTabelle)
&& COL_FINGERPRINT.equalsIgnoreCase(fkSpalte)) {
fkGefunden = true;
break;
}
}
}
if (!fkGefunden) {
fehler.add("Foreign Key von 'processing_attempt.fingerprint' auf 'document_record.fingerprint' fehlt");
}
}
// -------------------------------------------------------------------------
// Backup-Erstellung (Fall 2)
// -------------------------------------------------------------------------
/**
* Erstellt eine datierte Kopie der SQLite-Datei als Backup.
*
* <p>Das Backup-Dateiname-Schema lautet: {@code <original>.<timestamp>.bak},
* z. B. {@code data.db.20260430T120000Z.bak}.
* Bei einer Kollision wird ein Zähler angehängt.
*
* @throws DocumentPersistenceException wenn das Backup nicht angelegt werden kann
*/
private void createDatedBackup() {
Path dbPath = extractDbPath();
if (dbPath == null) {
logger.warn("Kein lokaler Dateipfad aus JDBC-URL ableitbar Backup übersprungen: {}", jdbcUrl);
return;
}
if (!Files.exists(dbPath)) {
logger.debug("Datenbankdatei existiert noch nicht kein Backup nötig.");
return;
}
String zeitstempel = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
.format(java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC));
Path backup = dbPath.resolveSibling(dbPath.getFileName() + "." + zeitstempel + ".bak");
// Kollisionsauflösung
int zaehler = 1;
while (Files.exists(backup)) {
backup = dbPath.resolveSibling(dbPath.getFileName() + "." + zeitstempel + "." + zaehler + ".bak");
zaehler++;
}
try {
Files.copy(dbPath, backup, StandardCopyOption.COPY_ATTRIBUTES);
logger.info("Backup der Datenbankdatei erstellt: {}", backup);
} catch (IOException e) {
String msg = "Backup der Datenbankdatei konnte nicht erstellt werden: " + e.getMessage();
logger.error(msg, e);
throw new DocumentPersistenceException(msg, e);
}
}
/**
* Leitet den Dateisystempfad aus der JDBC-URL ab.
*
* <p>Erwartet URLs der Form {@code jdbc:sqlite:/pfad/zur/datei.db}.
*
* @return der abgeleitete {@link Path} oder {@code null}, wenn kein Pfad ableitbar ist
*/
private Path extractDbPath() {
// Erwartet: jdbc:sqlite:/pfad/zur/datei oder jdbc:sqlite:C:/pfad/datei
String prefix = "jdbc:sqlite:";
if (!jdbcUrl.startsWith(prefix)) {
return null;
}
String pfad = jdbcUrl.substring(prefix.length());
if (pfad.isBlank()) {
return null;
}
try {
return Paths.get(pfad);
} catch (Exception e) {
logger.warn("Pfad aus JDBC-URL konnte nicht geparst werden: {}", pfad);
return null;
}
}
// -------------------------------------------------------------------------
// DataSource-Erstellung
// -------------------------------------------------------------------------
/**
* Erstellt eine {@link SQLiteDataSource} mit aktivierten Fremdschlüsseln.
*
* <p>Die Aktivierung über {@link SQLiteConfig#enforceForeignKeys(boolean)} stellt
* sicher, dass jede neue Verbindung automatisch {@code PRAGMA foreign_keys = ON}
* erhält ein einmaliges Statement nach dem Verbindungsaufbau wäre nicht ausreichend.
*
* @return eine konfigurierte {@link DataSource}; niemals {@code null}
*/
private DataSource createDataSource() {
SQLiteConfig config = new SQLiteConfig();
config.enforceForeignKeys(true);
SQLiteDataSource ds = new SQLiteDataSource(config);
ds.setUrl(jdbcUrl);
return ds;
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Liest alle Tabellennamen aus den Datenbankmetadaten (Kleinschreibung).
*
* @param meta Datenbankmetadaten
* @return Menge aller Tabellennamen in Kleinschreibung
* @throws SQLException bei technischen Datenbankfehlern
*/
private static Set<String> readTableNames(DatabaseMetaData meta) throws SQLException {
Set<String> names = new HashSet<>();
try (ResultSet rs = meta.getTables(null, null, "%", new String[]{"TABLE"})) {
while (rs.next()) {
names.add(rs.getString("TABLE_NAME").toLowerCase());
}
}
return names;
}
/**
* Liest alle Indexnamen aus den Datenbankmetadaten für beide fachlichen Tabellen.
*
* @param meta Datenbankmetadaten
* @return Menge aller Indexnamen in Kleinschreibung
* @throws SQLException bei technischen Datenbankfehlern
*/
private static Set<String> readIndexNames(DatabaseMetaData meta) throws SQLException {
Set<String> names = new HashSet<>();
for (String tabelle : new String[]{TABLE_DOCUMENT_RECORD, TABLE_PROCESSING_ATTEMPT}) {
try (ResultSet rs = meta.getIndexInfo(null, null, tabelle, false, false)) {
while (rs.next()) {
String indexName = rs.getString("INDEX_NAME");
if (indexName != null) {
names.add(indexName.toLowerCase());
}
}
}
}
return names;
}
}
@@ -3,7 +3,6 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Objects;
import java.util.function.Consumer;
@@ -24,6 +23,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* and processing attempt repositories.
*/
public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
private static final String ROLLBACK_FAILED_MSG = "Rollback fehlgeschlagen: {}";
private static final Logger logger = LogManager.getLogger(SqliteUnitOfWorkAdapter.class);
@@ -41,7 +42,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
public void executeInTransaction(Consumer<TransactionOperations> operations) {
Objects.requireNonNull(operations, "operations must not be null");
try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
try (Connection connection = SqliteConnectionFactory.open(jdbcUrl)) {
connection.setAutoCommit(false);
try {
@@ -57,7 +58,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
connection.rollback();
logger.debug("Transaktion zurückgerollt (Dokumentfehler): {}", e.getMessage());
} catch (SQLException rollbackEx) {
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx);
logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
}
throw e;
} catch (RuntimeException e) {
@@ -66,7 +67,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
connection.rollback();
logger.debug("Transaktion zurückgerollt (Laufzeitfehler): {}", e.getMessage());
} catch (SQLException rollbackEx) {
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx);
logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
}
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
} catch (SQLException e) {
@@ -75,7 +76,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
connection.rollback();
logger.debug("Transaktion zurückgerollt (SQL-Fehler): {}", e.getMessage());
} catch (SQLException rollbackEx) {
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx);
logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
}
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
}
@@ -171,7 +172,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
*/
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// Delete attempts first (FK constraint: processing_attempt document_record)
// Zuerst Versuche löschen (FK-Constraint: processing_attempt document_record)
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
@Override
@@ -181,7 +182,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
};
attemptRepo.deleteAllByFingerprint(fingerprint);
// Then delete the master record
// Dann den Stammsatz löschen
SqliteDocumentRecordRepositoryAdapter recordRepo =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
@Override
@@ -191,5 +192,45 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
};
recordRepo.deleteByFingerprint(fingerprint);
}
/**
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
* ohne die Versuchshistorie zu löschen.
* <p>
* Die Felder {@code overall_status}, {@code content_error_count},
* {@code transient_error_count} und {@code last_failure_instant} werden innerhalb
* der laufenden Transaktion per direktem SQL-UPDATE aktualisiert.
* Alle anderen Felder sowie alle {@code processing_attempt}-Einträge bleiben unverändert.
* <p>
* Ist kein Stammsatz für den Fingerprint vorhanden, kehrt die Methode stillschweigend zurück.
*
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
String sql = """
UPDATE document_record SET
overall_status = 'READY_FOR_AI',
content_error_count = 0,
transient_error_count = 0,
last_failure_instant = NULL
WHERE fingerprint = ?
""";
try (java.sql.PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, fingerprint.sha256Hex());
stmt.executeUpdate();
logger.debug("Status-Reset (feldgenau) für Fingerprint: {}", fingerprint.sha256Hex());
} catch (java.sql.SQLException e) {
String message = "Status-Reset fehlgeschlagen für Fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
}
}
@@ -1,35 +1,43 @@
/**
* SQLite persistence adapter for the two-level persistence model.
* SQLite-Persistenz-Adapter für das Zwei-Ebenen-Persistenzmodell.
*
* <h2>Purpose</h2>
* <p>This package contains the technical SQLite infrastructure for the persistence
* layer. It is the only place in the entire application where JDBC connections, SQL DDL,
* and SQLite-specific types are used. No JDBC or SQLite types leak into the
* {@code application} or {@code domain} modules.
* <h2>Zweck</h2>
* <p>Dieses Paket enthält die technische SQLite-Infrastruktur der Persistenzschicht.
* Es ist die einzige Stelle in der gesamten Anwendung, an der JDBC-Verbindungen,
* SQL-DDL und SQLite-spezifische Typen verwendet werden. Keine JDBC- oder
* SQLite-Typen verlassen dieses Paket in Richtung der {@code application}-
* oder {@code domain}-Module.
*
* <h2>Two-level persistence model</h2>
* <p>Persistence is structured in exactly two levels:
* <h2>Zwei-Ebenen-Persistenzmodell</h2>
* <p>Die Persistenz ist in genau zwei Ebenen strukturiert:
* <ol>
* <li><strong>Document master record</strong> ({@code document_record} table)
* one row per unique SHA-256 fingerprint; carries the current overall status,
* failure counters, and the most recently known source location.</li>
* <li><strong>Processing attempt history</strong> ({@code processing_attempt} table)
* one row per historised processing attempt; references the master record via
* fingerprint; attempt numbers are monotonically increasing per fingerprint.</li>
* <li><strong>Dokument-Stammsatz</strong> ({@code document_record}-Tabelle)
* eine Zeile pro eindeutigem SHA-256-Fingerprint; trägt den aktuellen
* Gesamtstatus, Fehlerzähler und den zuletzt bekannten Quellort.</li>
* <li><strong>Versuchshistorie</strong> ({@code processing_attempt}-Tabelle)
* eine Zeile pro historisiertem Verarbeitungsversuch; referenziert den
* Stammsatz über den Fingerprint; Versuchsnummern sind pro Fingerprint
* monoton steigend.</li>
* </ol>
*
* <h2>Schema initialisation timing</h2>
* <p>The {@link de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter}
* implements the
* <h2>Schema-Initialisierung mit Flyway</h2>
* <p>Der {@link de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter}
* implementiert den
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort}
* and must be called <em>once</em> at program startup, before the batch document
* processing loop begins. There is no lazy or hidden initialisation during document
* processing.
* und muss <em>einmal</em> beim Programmstart aufgerufen werden, bevor die
* Verarbeitungsschleife beginnt. Die Initialisierung unterscheidet drei Fälle:
* leere Datenbank, bestehende Datenbank ohne Flyway-History (Baseline-Eintragung
* nach vollständiger Schema-Prüfung) und Folgestart mit Flyway-History (idempotent).
*
* <h2>Architecture boundary</h2>
* <p>All JDBC connections, SQL statements, and SQLite-specific behaviour are strictly
* confined to this package. The application layer interacts exclusively through the
* port interfaces defined in
* <h2>Fremdschlüssel</h2>
* <p>Foreign-Key-Durchsetzung wird über {@code SQLiteConfig.enforceForeignKeys(true)}
* auf DataSource-Ebene aktiviert, sodass jede neue Verbindung automatisch
* {@code PRAGMA foreign_keys = ON} erhält.
*
* <h2>Architekturgrenze</h2>
* <p>Alle JDBC-Verbindungen, SQL-Anweisungen und SQLite-spezifisches Verhalten sind
* ausschließlich in diesem Paket gekapselt. Die Application-Schicht interagiert
* ausschließlich über die Port-Interfaces in
* {@code de.gecheckt.pdf.umbenenner.application.port.out}.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
@@ -0,0 +1,58 @@
-- Vollständiges Basisschema: Dokument-Stammsatz und Versuchshistorie.
-- Dieses Skript wird für neue Datenbanken ausgeführt (Fall 1).
-- Für bestehende Datenbanken mit konformem Schema wird nur eine Flyway-Baseline
-- eingetragen; das Skript wird in diesem Fall NICHT ausgeführt (Fall 2).
CREATE TABLE document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
last_known_source_locator TEXT NOT NULL,
last_known_source_file_name TEXT NOT NULL,
overall_status TEXT NOT NULL,
content_error_count INTEGER NOT NULL DEFAULT 0,
transient_error_count INTEGER NOT NULL DEFAULT 0,
last_failure_instant TEXT,
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_target_path TEXT,
last_target_file_name TEXT,
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
);
CREATE TABLE processing_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
status TEXT NOT NULL,
failure_class TEXT,
failure_message TEXT,
retryable INTEGER NOT NULL DEFAULT 0,
model_name TEXT,
prompt_identifier TEXT,
processed_page_count INTEGER,
sent_character_count INTEGER,
ai_raw_response TEXT,
ai_reasoning TEXT,
resolved_date TEXT,
date_source TEXT,
validated_title TEXT,
final_target_file_name TEXT,
ai_provider TEXT,
CONSTRAINT fk_processing_attempt_fingerprint
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
CONSTRAINT uq_processing_attempt_fingerprint_number
UNIQUE (fingerprint, attempt_number)
);
CREATE INDEX idx_processing_attempt_fingerprint
ON processing_attempt (fingerprint);
CREATE INDEX idx_processing_attempt_run_id
ON processing_attempt (run_id);
CREATE INDEX idx_document_record_overall_status
ON document_record (overall_status);
@@ -0,0 +1,64 @@
-- V2: Token-Erfassung mit Preis-Snapshot in processing_attempt;
-- neue model_price-Tabelle mit Composite Primary Key.
-- Verifizierter Stand: V1__initial_schema.sql ist die einzige bisherige
-- Migration im Projekt.
ALTER TABLE processing_attempt
ADD COLUMN input_tokens INTEGER
CHECK (input_tokens IS NULL OR (input_tokens >= 0 AND input_tokens <= 10000000));
ALTER TABLE processing_attempt
ADD COLUMN output_tokens INTEGER
CHECK (output_tokens IS NULL OR (output_tokens >= 0 AND output_tokens <= 10000000));
ALTER TABLE processing_attempt
ADD COLUMN cache_creation_input_tokens INTEGER
CHECK (cache_creation_input_tokens IS NULL OR (cache_creation_input_tokens >= 0 AND cache_creation_input_tokens <= 10000000));
ALTER TABLE processing_attempt
ADD COLUMN cache_read_input_tokens INTEGER
CHECK (cache_read_input_tokens IS NULL OR (cache_read_input_tokens >= 0 AND cache_read_input_tokens <= 10000000));
ALTER TABLE processing_attempt
ADD COLUMN price_input_per_token_nano_usd INTEGER
CHECK (price_input_per_token_nano_usd IS NULL OR (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000));
ALTER TABLE processing_attempt
ADD COLUMN price_output_per_token_nano_usd INTEGER
CHECK (price_output_per_token_nano_usd IS NULL OR (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000));
CREATE TABLE model_price (
provider TEXT NOT NULL,
model_name TEXT NOT NULL,
price_input_per_token_nano_usd INTEGER NOT NULL CHECK (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000),
price_output_per_token_nano_usd INTEGER NOT NULL CHECK (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000),
currency TEXT NOT NULL DEFAULT 'USD' CHECK (currency = 'USD'),
updated_at TEXT NOT NULL,
PRIMARY KEY (provider, model_name)
);
CREATE INDEX idx_processing_attempt_started_at_provider_fp_model
ON processing_attempt (started_at, ai_provider, fingerprint, model_name);
CREATE INDEX idx_processing_attempt_run_id_provider_model
ON processing_attempt (run_id, ai_provider, model_name);
-- Default-Preise (Stand 2026-05-08, in Nano-USD pro Token)
-- Quellen (abgerufen 2026-05-08):
-- OpenAI: https://openai.com/api/pricing/
-- Anthropic: https://www.anthropic.com/pricing
-- ON CONFLICT DO NOTHING: schuetzt vor manuell vorhandenen Default-Zeilen.
INSERT INTO model_price
(provider, model_name, price_input_per_token_nano_usd, price_output_per_token_nano_usd, currency, updated_at)
VALUES
('openai-compatible', 'gpt-4o-mini', 150, 600, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-4o', 2500, 10000, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-4.1', 2000, 8000, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-4.1-mini', 400, 1600, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-4.1-nano', 100, 400, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-5', 1250, 10000, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-5-mini', 250, 2000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-haiku-4-5-20251001', 1000, 5000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-sonnet-4-6', 3000, 15000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-opus-4-7', 5000, 25000, 'USD', '2026-05-08T00:00:00Z')
ON CONFLICT (provider, model_name) DO NOTHING;
@@ -214,10 +214,20 @@ class AnthropicClaudeAdapterIntegrationTest {
* where log output is not relevant to the assertion.
*/
private static class NoOpProcessingLogger implements ProcessingLogger {
@Override public void info(String message, Object... args) {}
@Override public void debug(String message, Object... args) {}
@Override public void warn(String message, Object... args) {}
@Override public void error(String message, Object... args) {}
@Override public void debugSensitiveAiContent(String message, Object... args) {}
@Override public void info(String message, Object... args) {
// intentionally empty
}
@Override public void debug(String message, Object... args) {
// intentionally empty
}
@Override public void warn(String message, Object... args) {
// intentionally empty
}
@Override public void error(String message, Object... args) {
// intentionally empty
}
@Override public void debugSensitiveAiContent(String message, Object... args) {
// intentionally empty
}
}
}
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
/**
* Unit tests for {@link FilesystemPromptPortAdapter}.
@@ -199,4 +200,135 @@ class FilesystemPromptPortAdapterTest {
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
}
// -------------------------------------------------------------------------
// savePrompt tests
// -------------------------------------------------------------------------
@Test
void savePrompt_shouldReturnSaved_whenTargetDirExistsAndWriteSucceeds() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_save.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Mein Prompt-Inhalt";
// When
PromptSaveResult result = adapter.savePrompt(content);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
PromptSaveResult.Saved saved = (PromptSaveResult.Saved) result;
assertThat(saved.absolutePath()).contains("prompt_save.txt");
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
}
@Test
void savePrompt_shouldPreserveUtf8Content_includingUmlauts() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_umlaut.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Ärger mit Überschriften und Schluß";
// When
PromptSaveResult result = adapter.savePrompt(content);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
}
@Test
void savePrompt_shouldPreserveLineEndings_withoutNormalization() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_lineendings.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Zeile 1\r\nZeile 2\nZeile 3\r\n";
// When
PromptSaveResult result = adapter.savePrompt(content);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
byte[] raw = Files.readAllBytes(promptFile);
assertThat(new String(raw, StandardCharsets.UTF_8)).isEqualTo(content);
}
@Test
void savePrompt_shouldOverwriteExistingFile_atomically() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_overwrite.txt");
Files.writeString(promptFile, "Alter Inhalt", StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
String newContent = "Neuer Inhalt";
// When
PromptSaveResult result = adapter.savePrompt(newContent);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(newContent);
}
@Test
void savePrompt_shouldReturnTargetDirectoryMissing_whenDirectoryDoesNotExist() {
// Given
Path nonExistentDir = tempDir.resolve("missing-subdir");
Path promptFile = nonExistentDir.resolve("prompt.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptSaveResult result = adapter.savePrompt("Inhalt");
// Then
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
PromptSaveResult.TargetDirectoryMissing missing = (PromptSaveResult.TargetDirectoryMissing) result;
assertThat(missing.message()).contains("missing-subdir");
}
@Test
void savePrompt_shouldThrowNullPointerException_whenContentIsNull() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_null.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
// When & Then
assertThatThrownBy(() -> adapter.savePrompt(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("content must not be null");
}
@Test
void savePrompt_shouldLeaveDirClean_whenTargetDirectoryIsMissing() {
// Given Verzeichnis existiert nicht; keine Temp-Datei soll zurückbleiben
Path nonExistentDir = tempDir.resolve("ghost-dir");
Path promptFile = nonExistentDir.resolve("prompt.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptSaveResult result = adapter.savePrompt("Inhalt");
// Then
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
// Verzeichnis wurde nicht angelegt (da Directory-Check fehlschlug)
assertThat(nonExistentDir).doesNotExist();
}
@Test
void savePrompt_roundTrip_loadAfterSaveReturnsSameContent() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_roundtrip.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Runde-Trip-Inhalt\nMit mehreren Zeilen.";
// When
PromptSaveResult saveResult = adapter.savePrompt(content);
PromptLoadingResult loadResult = adapter.loadPrompt();
// Then
assertThat(saveResult).isInstanceOf(PromptSaveResult.Saved.class);
assertThat(loadResult).isInstanceOf(PromptLoadingSuccess.class);
PromptLoadingSuccess success = (PromptLoadingSuccess) loadResult;
// loadPrompt trims the content; trim the expected too
assertThat(success.promptContent()).isEqualTo(content.trim());
}
}
@@ -24,11 +24,11 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/**
* Tests for the additive {@code ai_provider} column in {@code processing_attempt}.
* <p>
* Covers schema migration (idempotency, nullable default for existing rows),
* write/read round-trips for both supported provider identifiers, and
* backward compatibility with databases created before provider tracking was introduced.
* Tests für {@code ai_provider} in {@code processing_attempt}.
*
* <p>Prüft Schreib-/Lese-Roundtrips für beide Provider-Identifikatoren,
* Idempotenz der Initialisierung sowie das Verhalten bei Schemata,
* die nicht dem Zielschema entsprechen (harter Abbruch per Fall-2-Strategie).
*/
class SqliteAttemptProviderPersistenceTest {
@@ -64,25 +64,24 @@ class SqliteAttemptProviderPersistenceTest {
}
/**
* A database that already has the {@code processing_attempt} table without
* {@code ai_provider} (simulating an existing installation before this column was added)
* must receive the column via the idempotent schema evolution.
* Eine bestehende Datenbank ohne {@code ai_provider}-Spalte in {@code processing_attempt}
* entspricht nicht dem vollständigen Zielschema. Die Initialisierung muss mit einem
* klaren Fehler abbrechen, da kein stilles Heilen stattfindet.
*/
@Test
void addsProviderColumnOnExistingDbWithoutColumn() throws SQLException {
// Bootstrap schema without the ai_provider column (simulate legacy DB)
void existingDbOhneAiProviderSpalte_brichtAb() throws SQLException {
// Schema ohne ai_provider anlegen
createLegacySchema();
assertThat(columnExists("processing_attempt", "ai_provider"))
.as("ai_provider must not be present before evolution")
.as("ai_provider darf im Legacy-Schema noch nicht vorhanden sein")
.isFalse();
// Running initializeSchema must add the column
schemaAdapter.initializeSchema();
assertThat(columnExists("processing_attempt", "ai_provider"))
.as("ai_provider column must be added by schema evolution")
.isTrue();
// Initialisierung muss mit Fehler abbrechen (nicht konformes Schema)
org.junit.jupiter.api.Assertions.assertThrows(
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
() -> schemaAdapter.initializeSchema(),
"Erwarte Fehler bei nicht konformem Schema (fehlende ai_provider-Spalte)");
}
/**
@@ -101,25 +100,28 @@ class SqliteAttemptProviderPersistenceTest {
}
/**
* Rows that existed before the {@code ai_provider} column was added must have
* {@code NULL} as the column value, not a non-null default.
* Neue Versuche die ohne Provider-Information gespeichert werden (z. B. über
* {@code ProcessingAttempt.withoutAiFields}), müssen {@code null} als
* {@code ai_provider} zurückliefern.
*/
@Test
void existingRowsKeepNullProvider() throws SQLException {
// Create legacy schema and insert a row without ai_provider
createLegacySchema();
DocumentFingerprint fp = fingerprint("aa");
insertLegacyDocumentRecord(fp);
insertLegacyAttemptRow(fp, "READY_FOR_AI");
// Now evolve the schema
void neuerVersuchOhneProvider_haeltNullProviderNachSchreibenUndLesen() {
schemaAdapter.initializeSchema();
DocumentFingerprint fp = fingerprint("aa");
insertDocumentRecord(fp);
// Read the existing row ai_provider must be NULL
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
assertThat(attempts).hasSize(1);
assertThat(attempts.get(0).aiProvider())
.as("Existing rows must have NULL ai_provider after schema evolution")
java.time.Instant now = java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS);
ProcessingAttempt attemptOhneProvider = ProcessingAttempt.withoutAiFields(
fp, new RunId("run-null"), 1,
now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE,
"Err", "msg", true);
repository.save(attemptOhneProvider);
List<ProcessingAttempt> gelesen = repository.findAllByFingerprint(fp);
assertThat(gelesen).hasSize(1);
assertThat(gelesen.get(0).aiProvider())
.as("Versuche ohne Provider müssen null zurückgeben")
.isNull();
}
@@ -213,29 +215,24 @@ class SqliteAttemptProviderPersistenceTest {
}
/**
* Reading a database that was created without the {@code ai_provider} column
* (a pre-extension database) must succeed; the new field must be empty/null
* for historical attempts.
* Eine Datenbank mit nicht konformem Schema (fehlende Spalten, fehlende Indizes)
* wird von der Initialisierung mit einem klaren Fehler abgebrochen.
* Es findet kein stilles Heilen statt.
*/
@Test
void legacyDataReadingDoesNotFail() throws SQLException {
// Set up legacy schema with a row that has no ai_provider column
void nichtKonformesSchema_brichtMitAussagekraeftigemFehlerAb() throws SQLException {
// Legacy-Schema anlegen (fehlt: ai_provider, last_target_path, last_target_file_name,
// Indizes fehlen ebenfalls)
createLegacySchema();
DocumentFingerprint fp = fingerprint("ee");
insertLegacyDocumentRecord(fp);
insertLegacyAttemptRow(fp, "FAILED_RETRYABLE");
// Evolve schema now ai_provider column exists but legacy rows have NULL
schemaAdapter.initializeSchema();
// Reading must not throw and must return null for ai_provider
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
assertThat(attempts).hasSize(1);
assertThat(attempts.get(0).aiProvider())
.as("Legacy attempt (from before provider tracking) must have null aiProvider")
.isNull();
// Other fields must still be readable
assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
// Initialisierung muss abbrechen
org.junit.jupiter.api.Assertions.assertThrows(
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
() -> schemaAdapter.initializeSchema(),
"Erwarte Fehler bei nicht konformem Bestands-Schema");
}
/**
@@ -0,0 +1,97 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort.DatabaseCreationResult;
/**
* Tests für {@link SqliteDatabaseCreationAdapter}.
* <p>
* Prüft, dass eine neue, leere SQLite-Datei am übergebenen Temp-Pfad angelegt und
* vollständig per Flyway migriert wird, dass der Verbindungstest die Flyway-History
* verifiziert und dass Fehler im Verlauf zur Bereinigung der Temp-Datei führen.
*/
class SqliteDatabaseCreationAdapterTest {
@Test
void createAndInitialize_shouldRejectNullPath() {
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
assertThatThrownBy(() -> adapter.createAndInitialize(null))
.isInstanceOf(NullPointerException.class);
}
@Test
void createAndInitialize_shouldCreateAndMigrateNewSqliteFile(@TempDir Path tempDir) throws Exception {
Path tempFile = tempDir.resolve("new-db.sqlite.tmp");
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
assertThat(Files.exists(tempFile)).isTrue();
assertThat(Files.size(tempFile)).isGreaterThan(0);
// Schema verifizieren: Flyway-History und fachliche Tabellen müssen existieren
String jdbcUrl = "jdbc:sqlite:" + tempFile.toAbsolutePath().toString().replace('\\', '/');
try (Connection conn = DriverManager.getConnection(jdbcUrl);
Statement stmt = conn.createStatement()) {
try (ResultSet rs = stmt.executeQuery(
"SELECT count(*) FROM sqlite_master WHERE type='table' "
+ "AND name IN ('flyway_schema_history','document_record','processing_attempt')")) {
assertThat(rs.next()).isTrue();
assertThat(rs.getInt(1)).isEqualTo(3);
}
try (ResultSet rs = stmt.executeQuery(
"SELECT count(*) FROM flyway_schema_history WHERE success = 1")) {
assertThat(rs.next()).isTrue();
assertThat(rs.getInt(1)).isGreaterThanOrEqualTo(1);
}
}
}
@Test
void createAndInitialize_shouldOverwriteExistingTempFileBeforeMigration(@TempDir Path tempDir) throws Exception {
Path tempFile = tempDir.resolve("existing.tmp");
Files.writeString(tempFile, "rest-zustand");
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
// Die Datei wurde durch eine leere SQLite-Datei ersetzt der ursprüngliche Inhalt darf nicht mehr
// sichtbar sein.
assertThat(Files.size(tempFile)).isGreaterThan(0);
assertThat(Files.readString(tempFile, java.nio.charset.StandardCharsets.ISO_8859_1))
.doesNotContain("rest-zustand");
}
@Test
void createAndInitialize_shouldFailAndCleanup_whenParentDirectoryDoesNotExist(@TempDir Path tempDir)
throws SQLException {
Path missingParent = tempDir.resolve("does-not-exist").resolve("child.tmp");
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
DatabaseCreationResult result = adapter.createAndInitialize(missingParent);
assertThat(result).isInstanceOf(DatabaseCreationResult.Failure.class);
DatabaseCreationResult.Failure failure = (DatabaseCreationResult.Failure) result;
assertThat(failure.phase())
.isIn(DatabaseCreationPort.DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
DatabaseCreationPort.DatabaseCreationResult.Phase.CONNECTION_TEST,
DatabaseCreationPort.DatabaseCreationResult.Phase.FILE_CREATION);
assertThat(Files.exists(missingParent)).isFalse();
}
}

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