62 Commits

Author SHA1 Message Date
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
121 changed files with 11687 additions and 877 deletions
+26 -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 - `--config <pfad>` steht für GUI und headless zur Verfügung
- kein Webserver - kein Webserver
- kein Applikationsserver - kein Applikationsserver
- keine Dauerlauf-Anwendung - keine Dauerlauf-Anwendung (Ausnahme: GUI-Modus mit aktivem Scheduler, s. Scheduler-Ausnahme)
- kein interner Scheduler - kein interner Scheduler (Ausnahme: optionaler GUI-Scheduler ab V3.2, s. Scheduler-Ausnahme)
- das Shade-JAR bleibt das primäre Distributionsartefakt - das Shade-JAR bleibt das primäre Distributionsartefakt
- zusätzlicher nativer Windows-Installer (MSI) ab V3.0 via Maven-Profil `release` (jpackage, WiX Toolset 3.x im PATH erforderlich); der Normalbuild `mvn clean verify` bleibt vom Profil unberührt und benötigt kein WiX - zusätzlicher nativer Windows-Installer (MSI) ab V3.0 via Maven-Profil `release` (jpackage, WiX Toolset 3.x im PATH erforderlich); der Normalbuild `mvn clean verify` bleibt vom Profil unberührt und benötigt kein WiX
- Log4j2 für Logging - Log4j2 für Logging
@@ -77,9 +77,28 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
- `pdf-umbenenner-application` - `pdf-umbenenner-application`
- `pdf-umbenenner-adapter-in-cli` - `pdf-umbenenner-adapter-in-cli`
- `pdf-umbenenner-adapter-in-gui` - `pdf-umbenenner-adapter-in-gui`
- `pdf-umbenenner-adapter-in-scheduler`
- `pdf-umbenenner-adapter-out` - `pdf-umbenenner-adapter-out`
- `pdf-umbenenner-bootstrap` - `pdf-umbenenner-bootstrap`
## Scheduler-Ausnahme (ab V3.2)
Ab V3.2 enthält der GUI-Modus einen optionalen internen Scheduler, der periodisch
automatische Verarbeitungsläufe anstößt. Die folgenden Regeln gelten abweichend von
den allgemeinen Leitplanken:
- Der Scheduler ist **ausschließlich im GUI-Modus** verfügbar. Im headless Betrieb werden
`scheduler.enabled` und `scheduler.interval.seconds` vollständig ignoriert.
- Das Modul `pdf-umbenenner-adapter-in-scheduler` erfüllt eine gemischte Rolle als
technischer Treiber und Adapter. Dies ist ein bewusster Architekturkompromiss, kein
Architekturbruch.
- `pdf-umbenenner-adapter-in-scheduler` enthält **kein JavaFX**.
- **Kein WatchService:** Der Scheduler löst reguläre Verarbeitungsläufe periodisch aus;
er nutzt keinen Dateisystem-Event-Mechanismus.
- Das bestehende Datenbankschema bleibt in V3.2 unverändert; keine
Scheduler-spezifische Schemaerweiterung.
- Token- und Kostentracking sind nicht Bestandteil von V3.2.
## Architekturregeln ## Architekturregeln
- Strikte **hexagonale Architektur / Ports and Adapters** - Strikte **hexagonale Architektur / Ports and Adapters**
- Abhängigkeiten zeigen immer **nach innen** - Abhängigkeiten zeigen immer **nach innen**
@@ -151,6 +170,8 @@ Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technis
Verhaltensänderungen seit V2.9: Die GUI startet maximiert, und die zuletzt geladene Konfigurationsdatei wird beim Start automatisch wieder geladen; existiert sie nicht mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext. Verhaltensänderungen seit V2.9: Die GUI startet maximiert, und die zuletzt geladene Konfigurationsdatei wird beim Start automatisch wieder geladen; existiert sie nicht mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
**V3.2 ist abgeschlossen.** Der GUI-Modus wurde um einen optionalen automatischen Scheduler erweitert (neuer Tab „Scheduler"). Der Scheduler startet periodisch Verarbeitungsläufe; Intervall und Autostart sind konfigurierbar. Während der Scheduler aktiv ist, sind der Konfigurations-Tab und das manuelle Starten von Läufen gesperrt. Im headless Betrieb werden Scheduler-Parameter vollständig ignoriert.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert. Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert.
## Statussemantik ## Statussemantik
@@ -301,6 +322,8 @@ Verbindlich zweckmäßige Parameter:
- `log.ai.sensitive` sensible KI-Logausgabe freischalten (Boolean, Default: `false`) - `log.ai.sensitive` sensible KI-Logausgabe freischalten (Boolean, Default: `false`)
- `runtime.lock.file` Lock-Datei (optional) - `runtime.lock.file` Lock-Datei (optional)
- `log.directory` Log-Verzeichnis (optional) - `log.directory` Log-Verzeichnis (optional)
- `scheduler.enabled` Scheduler im GUI-Modus aktivieren (Boolean, Default: `false`; wird im headless Betrieb vollständig ignoriert)
- `scheduler.interval.seconds` Intervall zwischen automatischen Läufen in Sekunden (Integer >= 30, Pflicht wenn `scheduler.enabled=true`; wird im headless Betrieb vollständig ignoriert)
Pro Provider-Familie existiert ein eigener Parameter-Namensraum: Pro Provider-Familie existiert ein eigener Parameter-Namensraum:
@@ -339,7 +362,7 @@ Verbindlicher Ablauf:
- keine OCR innerhalb der Java-Anwendung - keine OCR innerhalb der Java-Anwendung
- keine DMS-Funktionalität - keine DMS-Funktionalität
- kein menschlicher Review-Workflow in der Anwendung - kein menschlicher Review-Workflow in der Anwendung
- keine interne Scheduler-Logik - keine interne Scheduler-Logik außerhalb des optionalen GUI-Schedulers (s. Scheduler-Ausnahme)
- keine Architekturbrüche - keine Architekturbrüche
- keine neuen Bibliotheken oder Frameworks ohne klare Notwendigkeit und Begründung - keine neuen Bibliotheken oder Frameworks ohne klare Notwendigkeit und Begründung
- **keine** automatische Fallback-Umschaltung zwischen KI-Providern - **keine** automatische Fallback-Umschaltung zwischen KI-Providern
Vendored
+10
View File
@@ -87,6 +87,16 @@ EOF
} }
} // stage: Maven Build } // stage: Maven Build
stage('SonarQube Analyse') {
steps {
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
withSonarQubeEnv('SonarQube') {
sh "mvn sonar:sonar -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} -Dsonar.projectKey=pdf-umbenenner -Dsonar.projectName='PDF KI Renamer'"
}
}
}
} // stage: SonarQube Analyse
stage('Publish PIT Coverage') { stage('Publish PIT Coverage') {
steps { steps {
recordCoverage( recordCoverage(
+104 -4
View File
@@ -63,7 +63,7 @@ mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
### Umfang der GUI ### Umfang der GUI
Die GUI enthält drei Tabs: Die GUI enthält fünf Tabs:
- **Tab „Konfiguration"** Editor, Validierungs- und technische Testoberfläche für - **Tab „Konfiguration"** Editor, Validierungs- und technische Testoberfläche für
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei, die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
@@ -75,6 +75,14 @@ Die GUI enthält drei Tabs:
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop** ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei. über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin. Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
- **Tab „Scheduler"** Optionaler automatischer Scheduler für periodische Verarbeitungsläufe.
Kann gestartet, gestoppt und mit einem konfigurierten Intervall betrieben werden. Während
der Scheduler aktiv ist, sind Tab 1 „Konfiguration" und der manuelle Lauf gesperrt.
Erfordert `scheduler.enabled=true` und ein gültiges `scheduler.interval.seconds` in der
gespeicherten Konfiguration.
- **Tab „Verlauf"** Ansicht aller bisher verarbeiteten Dokumente mit Status, Dateinamen
und Verarbeitungsdetails direkt aus der SQLite-Datenbank. Ermöglicht Status-Reset und
Löschung einzelner Einträge.
- **Tab „Prompt"** Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt - **Tab „Prompt"** Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt
aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel). aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel).
Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`). Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`).
@@ -88,6 +96,37 @@ kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz
ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf
wird jedoch nicht technisch erkannt oder blockiert. wird jedoch nicht technisch erkannt oder blockiert.
### Automatischer Scheduler
Der GUI-Tab „Scheduler" ermöglicht den Betrieb eines optionalen, periodisch laufenden
Schedulers, der automatisch Verarbeitungsläufe anstößt.
**Konfigurationsparameter:**
| Parameter | Beschreibung | Standard |
|---|---|---|
| `scheduler.enabled` | Scheduler im GUI-Modus aktivieren (`true`/`false`); wird im headless Betrieb ignoriert | `false` |
| `scheduler.interval.seconds` | Intervall zwischen automatischen Läufen in Sekunden (Integer >= 30; Pflicht wenn `scheduler.enabled=true`); wird im headless Betrieb ignoriert | |
Ungültige Werte (kein Integer, < 30 oder leer bei `scheduler.enabled=true`) verhindern den
Scheduler-Start und werden im GUI-Tab als Fehler gemeldet.
**Autostart:** Ist `scheduler.enabled=true` in der gespeicherten Konfiguration, startet der
Scheduler automatisch, wenn die Konfiguration beim GUI-Start geladen wird. Der erste
Verarbeitungslauf beginnt **unmittelbar** nach dem Scheduler-Start (kein initiales Warten).
**Headless-Betrieb:** Im headless Betrieb werden `scheduler.enabled` und
`scheduler.interval.seconds` vollständig ignoriert. Der Scheduler ist ausschließlich im
GUI-Modus verfügbar.
**Sperrverhalten:** Solange der Scheduler aktiv ist, ist Tab 1 „Konfiguration" gesperrt
(Bearbeitungssperre mit Hinweisbanner). Manuelles Starten eines Laufs ist ebenfalls nicht
möglich. Nach dem Stoppen des Schedulers werden beide Sperren automatisch aufgehoben.
**Schließen der Anwendung:** Versucht der Benutzer das Fenster zu schließen, während der
Scheduler aktiv ist oder ein Lauf läuft, erscheint ein Informationsdialog. Das Schließen
wird blockiert, bis der Scheduler gestoppt und kein Lauf mehr aktiv ist.
--- ---
## Voraussetzungen ## Voraussetzungen
@@ -229,6 +268,8 @@ Nur der **aktive** Provider muss vollständig konfiguriert sein. Der inaktive Pr
| `log.directory` | Log-Verzeichnis | `./logs/` | | `log.directory` | Log-Verzeichnis | `./logs/` |
| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | | `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` |
| `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` | | `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` |
| `scheduler.enabled` | Scheduler im GUI-Modus aktivieren (`true`/`false`); wird im headless Betrieb ignoriert | `false` |
| `scheduler.interval.seconds` | Intervall in Sekunden (Integer >= 30; Pflicht wenn `scheduler.enabled=true`); wird im headless Betrieb ignoriert | |
### API-Schlüssel ### API-Schlüssel
@@ -425,7 +466,27 @@ Die Anwendung verwendet eine exklusive Lock-Datei, um parallele Instanzen zu ver
Wenn bereits eine Instanz läuft, beendet sich die neue Instanz sofort mit Exit-Code `1`. Wenn bereits eine Instanz läuft, beendet sich die neue Instanz sofort mit Exit-Code `1`.
Der Pfad der Lock-Datei ist über `runtime.lock.file` konfigurierbar. Der Pfad der Lock-Datei ist über `runtime.lock.file` konfigurierbar.
Ohne Konfiguration wird `pdf-umbenenner.lock` im Arbeitsverzeichnis verwendet.
### Pfadauflösung der Lock-Datei
| Pfadtyp | Verhalten |
|---|---|
| **Absoluter Pfad** | Wird direkt verwendet. Schlägt das Anlegen der Lock-Datei fehl, bricht der Start mit einer klaren Fehlermeldung ab kein Fallback. |
| **Relativer oder unkonfigurierter Pfad** | Zweistufige Auflösung: (1) relativ zum Verzeichnis der JAR-Datei (`CodeSource.getLocation()`), (2) Fallback auf das Benutzerverzeichnis (`user.home`). Erst wenn auch `user.home` fehlschlägt, bricht der Start ab. |
Fehlende übergeordnete Verzeichnisse werden automatisch angelegt.
Der tatsächlich verwendete absolute Pfad der Lock-Datei wird beim Start auf INFO-Level geloggt, z. B.:
```
Lock-Datei: C:\Users\Funny\Documents\pdf-umbenenner.lock
```
Diese Auflösungslogik gilt sowohl für den GUI- als auch für den headless Start.
> **Empfehlung für den MSI-Betrieb:** Da das Installationsverzeichnis `C:\Program Files\`
> schreibgeschützt ist, muss `runtime.lock.file` als absoluter Pfad auf ein beschreibbares
> Verzeichnis zeigen (z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock`).
--- ---
@@ -435,11 +496,50 @@ Die SQLite-Datei enthält:
- **Dokument-Stammsätze**: Gesamtstatus, Fehlerzähler, letzter Zieldateiname, Zeitstempel - **Dokument-Stammsätze**: Gesamtstatus, Fehlerzähler, letzter Zieldateiname, Zeitstempel
- **Versuchshistorie**: Jeder Verarbeitungsversuch mit Modell, Prompt-Identifikator, - **Versuchshistorie**: Jeder Verarbeitungsversuch mit Modell, Prompt-Identifikator,
KI-Rohantwort, Reasoning, Datum, Titel und Fehlerstatus KI-Rohantwort, Reasoning, Datum, Titel, Fehlerstatus und Fehlerdetails
Die Datenbank ist die führende Wahrheitsquelle für Bearbeitungsstatus und Nachvollziehbarkeit. Die Datenbank ist die führende Wahrheitsquelle für Bearbeitungsstatus und Nachvollziehbarkeit.
Sie muss nicht manuell verwaltet werden das Schema wird beim Start automatisch initialisiert. Sie muss nicht manuell verwaltet werden das Schema wird beim Start automatisch initialisiert.
### Fehlerursache im Verlauf-Tab
Verarbeitungsversuche mit Status `FAILED_FINAL`, `FAILED_RETRYABLE` oder
`SKIPPED_FINAL_FAILURE` speichern eine nutzerverständliche Fehlerursache
(`failure_details`). Diese wird im Verlauf-Tab im Detailbereich des jeweiligen
Dokuments angezeigt. Ältere Einträge ohne Fehlerdetails zeigen einen Platzhaltertext.
Fehlerdetails werden auf 1000 Zeichen begrenzt und enthalten keine rohen
Provider-Meldungen oder API-Schlüssel.
### Neue Datenbank anlegen
Über den Menüpunkt **Datenbank → Neue Datenbank anlegen...** kann aus der GUI
heraus eine neue, leere SQLite-Datenbank erstellt und sofort aktiviert werden,
ohne die Anwendung neu zu starten.
**Ablauf:**
1. Dateidialog öffnet (Filter: `*.sqlite` und `*.db`); Zieldatei wählen oder eingeben.
2. Sicherheitsprüfung: aktive und gewählte Datei werden normalisiert verglichen
(case-insensitive unter Windows). Bei Übereinstimmung erscheint eine Fehlermeldung.
3. Bei bereits existierender Fremddatei: Bestätigungsdialog „Die Datei existiert bereits.
Überschreiben?"
4. Neue SQLite-Datei wird als temporäre Datei erzeugt, Flyway führt alle Migrationsskripte
auf neuesten Stand aus, dann Verbindungstest.
5. Nach erfolgreichem Test: atomarer Move zur Zieldatei.
6. Aktive Datenbankverbindung der Anwendung wechselt zur neuen DB.
7. Der Verlauf-Tab lädt neu und zeigt „Noch keine Verarbeitungen vorhanden."
8. Die Statuszeile aktualisiert den DB-Pfad.
> **Wichtig:** Die Konfigurationsdatei wird durch den Wechsel automatisch als geändert
> markiert. **Konfiguration speichern**, damit die neue Datenbank beim nächsten Start
> der Anwendung verwendet wird.
**Fehlerfall:** Schlägt ein Schritt fehl, bleibt die bisherige Datenbank unverändert
in Betrieb. Die temporäre Datei wird gelöscht. Ein Fehlerdialog erscheint.
Der Menüpunkt ist nur aktiv, wenn kein Verarbeitungslauf läuft.
Der headless Betrieb ist von dieser Funktion nicht betroffen.
--- ---
## Build und Packaging ## Build und Packaging
@@ -714,7 +814,7 @@ Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md)
- Keine eingebaute OCR-Funktion - Keine eingebaute OCR-Funktion
- Kein Web-UI, keine REST-API - Kein Web-UI, keine REST-API
- Die GUI ermöglicht Konfiguration, Validierung, technische Diagnose und die Ausführung von Verarbeitungsläufen mit integrierter PDF-Vorschau und editierbarem Dateiname - Die GUI ermöglicht Konfiguration, Validierung, technische Diagnose und die Ausführung von Verarbeitungsläufen mit integrierter PDF-Vorschau und editierbarem Dateiname
- Kein interner Scheduler der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`) - Kein interner Scheduler im headless Betrieb der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`); im GUI-Modus steht optional ein interner Scheduler zur Verfügung (Tab „Scheduler")
- Quelldateien werden nie überschrieben, verschoben oder gelöscht - Quelldateien werden nie überschrieben, verschoben oder gelöscht
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen - Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
- Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet - Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet
+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.297`
- Freigabedatum: 2026-05-07
- **Status:** freigegeben
---
## Zielsetzung von V3.2
V3.2 ist der Übergang vom manuellen Batch-Tool zur autonomen
Dauerläufer-Anwendung. Ein einziges, klar abgegrenztes Hauptfeature:
**#22 Automatischer Scheduler:** Die Anwendung überwacht den konfigurierten
Quellordner dauerhaft im Hintergrund und startet die Verarbeitungspipeline
automatisch, sobald neue PDF-Dateien erkannt werden. Der Nutzer steuert
den Scheduler ausschließlich über den neuen Tab „Scheduler".
V3.2 ist eine reine Scheduler-Veranstaltung. Token- und Kosten-Tracking (#74)
wurde bewusst herausgelöst und bekommt eine eigene saubere Spezifikation in
V3.x inklusive Modell-Preistabelle, Persistenz-Strategie und EUR-Währung.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt vollständig
unverändert. Hexagonale Architektur, Modulstruktur, headless-Betrieb,
`.properties`-Konfigurationswahrheit und Flyway-DB-Evolution bleiben
unangetastet.
---
## Umgesetzte Features
| # | Kategorie | Beschreibung |
|---|---|---|
| #22 | Hauptfeature | Automatischer Scheduler: `ScheduledExecutorService`-Polling mit `scheduleWithFixedDelay`; Initial Delay 0 (erster Tick sofort); konfigurierbares Intervall (Minimum 30 s); neuer Tab „Scheduler" mit Start/Stop, Statusanzeige, Countdown, letzter Lauf, Gesamtzähler; OS-Lock auf `.properties` während Scheduler läuft; Konfig-Tab read-only bei aktivem Lock; manuelle Läufe bei aktivem Scheduler gesperrt; App-Schließen-Guard |
### Neue Architektur-Komponenten
| Neu | Anzahl | Bemerkung |
|---|---|---|
| Neues Maven-Modul | 1 | `pdf-umbenenner-adapter-in-scheduler` |
| Inbound-Port-Interfaces | 1 | `SchedulerControlUseCase` |
| Application-Use-Cases | 1 | `DefaultSchedulerControlUseCase` |
| Outbound-Ports | 3 | `SchedulerPort`, `ConfigurationFileLockPort`, `SchedulerSettingsPort` |
| Funktionale Interfaces | 1 | `BatchRunTrigger` mit sealed `BatchRunTriggerResult` |
| Neue Adapter | 2 | `ScheduledExecutorServiceSchedulerAdapter`, `FileChannelConfigurationAccessAdapter` |
| GUI-Komponenten neu | 2 | `GuiSchedulerTab`, `GuiStatusRefreshTimeline` |
| Bootstrap-Refactoring | | Init/Run-Trennung: `GuiShellContext` immer, `ApplicationRunContext` bei valider Config; `GuiApplicationContextInitializer`-Callback für Auto-Load-Pfad |
| Flyway-Migration | 0 | Keine DB-Migration in V3.2 |
Kontrollierte Architekturausnahme: CLAUDE.md wurde um die Scheduler-Ausnahme
erweitert. „Keine Dauerlauf-Anwendung" und „kein interner Scheduler" gelten
ab V3.2 nur noch für den headless-Pfad.
### Zusätzliche Verbesserungen (Produkttest-Nachbesserungen)
| Beschreibung |
|---|
| `ApplicationRunContext` wird nun auch beim Auto-Load-Pfad (ohne `--config`) korrekt aufgebaut via `GuiApplicationContextInitializer`-Callback |
| Double-Lock-Bug im `BatchRunTrigger`-Lambda behoben: kein eigenes `tryAcquire()` mehr, Lock ausschließlich in `execute()` |
| Stop-Button-Wiring-Bug behoben: `GuiStatusRefreshTimeline` liest jetzt den Live-Use-Case aus dem Workspace statt aus dem unveränderlichen `GuiStartupContext` |
| `installSchedulerCloseGuard` analog gefixt (gleiches Wiring-Problem) |
| `loadHistoryOverviewForGui` und 6 weitere GUI-Methoden im `BootstrapRunner` nutzen bei vorhandenem `ApplicationRunContext` direkt den Repository-Adapter statt Config neu zu laden verhindert IOException bei aktivem Config-Lock |
| Autostart-Feature entfernt: Scheduler startet nie automatisch, immer nur auf explizite Nutzeraktion |
| `RunSummary`-Zählung im Scheduler-Tab korrigiert: `PROPOSAL_READY` zählt korrekt als Erfolg; Gesamtzähler seit Scheduler-Start eingeführt |
| Java-Preferences-Knoten auf fixen String `de/gecheckt/pdf-umbenenner` umgestellt verhindert Verlust des gespeicherten Config-Pfads nach Code-Änderungen |
---
## Verbindlich verifizierte Spec-Punkte
- Scheduler startet nur auf explizite Nutzeraktion kein Autostart
- Erster Tick läuft sofort nach Scheduler-Start (Initial Delay 0)
- `scheduleWithFixedDelay`: nächster Tick erst N Sekunden nach Laufende
- Laufkollision via nicht-blockierendem `RunLockPort.tryAcquire()` kein Queuing
- Manuelle Läufe bei aktivem Scheduler gesperrt (deterministisches Verhalten)
- OS-Lock auf `.properties` während Scheduler läuft: Konfig-Tab read-only,
Speichern-Button deaktiviert, Eingabefelder nicht editierbar
- Verlauf-Tab funktioniert korrekt bei aktivem Config-Lock
- Stop während aktivem Lauf: Batch läuft zu Ende, danach `STOPPED`
- App-Schließen bei aktivem Scheduler: Hinweisdialog, App schließt nicht
- `SchedulerStatus` als immutable Snapshot via `AtomicReference`
- `SchedulerState` mit 5 Werten: `STOPPED`, `STARTING`, `RUNNING_IDLE`,
`RUNNING_BATCH_ACTIVE`, `STOPPING_BATCH_ACTIVE`
- No-op-Lauf (keine Kandidaten): „keine neuen Dokumente"; kein Fehlerstatus
- Scheduler-Tab zeigt korrekte Anzeige: letzter Lauf + Gesamtzähler
- Exception im Tick: gefangen, ERROR-geloggt, Executor läuft weiter
- Non-Daemon-Thread; sauberer Shutdown via `awaitTermination`
- Kein JavaFX im Modul `adapter-in-scheduler`
- PIT im neuen Modul explizit deaktiviert
- Code-Kommentare auf Deutsch; Logging auf Deutsch
- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
- Flyway ist die einzige Schema-Evolutionsquelle keine Migration in V3.2
---
## Headless-Kompatibilität
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten.
Scheduler-Properties (`scheduler.enabled`, `scheduler.interval.seconds`)
werden im headless-Modus weder gelesen noch validiert. Der headless-Pfad
verwendet keinen Scheduler-Codepfad und keinen Config-Lock.
---
## Datenbank-Migration
**Keine.** Das DB-Schema bleibt unverändert auf V1 (`V1__initial_schema.sql`).
Es wurden keine neuen Spalten und keine neuen Tabellen angelegt.
---
## Produkttest
**Produkttest: bestanden**
Manueller GUI-Produkttest gegen echten KI-Provider mit echten PDFs
abgeschlossen. Der Scheduler hat PDFs automatisch erkannt, per KI benannt
und in den Zielordner verschoben vollautomatisch ohne Nutzeraktion.
Alle wesentlichen Szenarien (Start/Stop, No-op-Lauf, aktive Verarbeitung,
Verlauf-Tab bei aktivem Scheduler, App-Schließen-Guard) wurden verifiziert.
---
## Bekannte Einschränkungen
| Einschränkung | Bewertung |
|---|---|
| JavaFX `NullPointerException` beim Schließen (`GraphicsPipeline.getPipeline() == null`) | JavaFX-interner Fehler nach Shutdown; kein Fehler im Anwendungscode; kein Datenverlust; kein Handlungsbedarf |
| Unvollständige PDFs (noch im Kopiervorgang) können temporär `FAILED_RETRYABLE` erzeugen | Erwartet; bestehende Retry-Semantik behandelt das korrekt beim nächsten Tick |
---
## Nicht in V3.2
- Token- und Kosten-Tracking (#74) → V3.x (eigene Spezifikation mit
Modell-Preistabelle, Persistenz-Strategie, EUR-Währung)
- Headless-Daemon-Betrieb des Schedulers (`--watch`-Flag) → V3.x
- Java WatchService (ereignisgesteuerte Ordnerüberwachung) → V3.x
- Windows-Service-Integration (WinSW o.ä.) → V3.x
- Modell-Filterung (OpenAI-Snapshots ausblenden) → V3.x
- Dark Mode (#70) → V3.x
- F1-Hilfe (#69) → V3.x
- Log-Viewer in der GUI (#72) → V3.x
- Excel-Export (#75) → V3.x
- Automatische Update-Prüfung (#76) → V3.x
- Neue KI-Provider, Architekturbrüche
- Änderung der fachlichen Kernverarbeitung des PDF-Umbenenners
---
## Nächste Version
**V3.x** Token- und Kosten-Tracking als eigenständiges, vollständig
durchdachtes Feature: Modell-Preistabelle (pro Modell, nicht pro Provider),
EUR-Währung, Kostenanzeige im Summary-Banner, Modell-Filterung für
OpenAI-kompatible Provider.
---
## Freigabeaussage
V3.2 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der
hexagonalen Architektur sind eingehalten. Das neue Modul `adapter-in-scheduler`
ist korrekt eingebunden (kein JavaFX, PIT deaktiviert, flatten aktiv).
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert
gegenüber V3.1. Headless-Betrieb vollständig unberührt. Manueller
Produkttest bestanden. Keine Release-Blocker.
+244 -33
View File
@@ -8,18 +8,20 @@ verwalten und technisch prüfen möchten.
## 1. Zweck und Scope der GUI ## 1. Zweck und Scope der GUI
Die GUI gliedert sich in vier feste Tabs: Die GUI gliedert sich in fünf feste Tabs:
- **Tab 1 „Konfiguration"** Editor, Validierungsoberfläche und technische - **Tab 1 „Konfiguration"** Editor, Validierungsoberfläche und technische
Test-/Diagnoseoberfläche für die `.properties`-Datei. Test-/Diagnoseoberfläche für die `.properties`-Datei.
- **Tab 2 „Verarbeitungslauf"** Start eines Batch-Laufs aus der GUI mit - **Tab 2 „Verarbeitungslauf"** Start eines Batch-Laufs aus der GUI mit
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13). Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
- **Tab 3 „Verlauf"** Ansicht aller bisher verarbeiteten Dokumente mit Status - **Tab 3 „Scheduler"** Optionaler automatischer Scheduler für periodische
und Verarbeitungsdetails aus der SQLite-Datenbank (siehe Abschnitt 16). Verarbeitungsläufe (siehe Abschnitt 14).
- **Tab 4 „Prompt"** Editor zum Lesen, Bearbeiten und Speichern der - **Tab 4 „Verlauf"** Ansicht aller bisher verarbeiteten Dokumente mit Status
konfigurierten KI-Prompt-Datei (siehe Abschnitt 17). und Verarbeitungsdetails aus der SQLite-Datenbank (siehe Abschnitt 17).
- **Tab 5 „Prompt"** Editor zum Lesen, Bearbeiten und Speichern der
konfigurierten KI-Prompt-Datei (siehe Abschnitt 18).
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 18). Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 19).
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
`--headless` der empfohlene Weg. `--headless` der empfohlene Weg.
@@ -337,13 +339,23 @@ vorbelegt.
## 8. Dirty-State und Schutzdialoge ## 8. Dirty-State und Schutzdialoge
### Konfigurations-Tab
Sobald eine geladene oder neu erzeugte Konfiguration bearbeitet wird, gilt der Sobald eine geladene oder neu erzeugte Konfiguration bearbeitet wird, gilt der
Editor als „dirty" (ungespeicherte Änderungen). Zwei visuelle Markierungen Editor als „dirty" (ungespeicherte Änderungen). Drei visuelle Markierungen
zeigen diesen Zustand an: zeigen diesen Zustand an:
- Ein **`*`**-Präfix im **Tab-Titel**: `* Konfiguration`
- Ein **`*`**-Präfix im Fenstertitel - Ein **`*`**-Präfix im Fenstertitel
- Ein kleines **„geändert"**-Label im Header - Ein kleines **„geändert"**-Label im Header
Das Dirty-Flag wird über einen **Baseline-Snapshot** ermittelt: Beim Laden einer
Konfiguration wird ein Snapshot des geladenen Zustands gespeichert. Erst wenn
der aktuelle Formularinhalt vom Snapshot abweicht, erscheint der Dirty-Indikator.
Programmgesteuertes Laden und Normalisieren von Feldinhalten lösen keinen
Dirty-State aus. Auch ein DB-Pfad-Wechsel über „Neue Datenbank anlegen..."
(Abschnitt 17a) versetzt den Konfigurations-Tab in den Dirty-State.
Vor den Aktionen „Neu", „Öffnen" und beim Schließen des Fensters prüft die GUI, Vor den Aktionen „Neu", „Öffnen" und beim Schließen des Fensters prüft die GUI,
ob ungespeicherte Änderungen vorhanden sind. Ist dies der Fall, erscheint ein ob ungespeicherte Änderungen vorhanden sind. Ist dies der Fall, erscheint ein
Schutzdialog mit drei Optionen: Schutzdialog mit drei Optionen:
@@ -354,6 +366,12 @@ Schutzdialog mit drei Optionen:
| **Verwerfen** | Verwirft die Änderungen und führt die Aktion aus | | **Verwerfen** | Verwirft die Änderungen und führt die Aktion aus |
| **Abbrechen** | Bricht die Aktion ab; die Änderungen bleiben erhalten | | **Abbrechen** | Bricht die Aktion ab; die Änderungen bleiben erhalten |
### Prompt-Tab
Der Prompt-Tab zeigt ebenfalls ein Asterisk im Tab-Titel (`Prompt *`), sobald der
TextArea-Inhalt vom gespeicherten Stand abweicht. Das Verhalten ist identisch zum
Konfigurations-Tab (Schutzdialog, Reset nach Speichern).
--- ---
## 9. `.bak`-Sicherung beim Überschreiben und Legacy-Migration ## 9. `.bak`-Sicherung beim Überschreiben und Legacy-Migration
@@ -488,7 +506,7 @@ in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein.
Farbe ist niemals das einzige Unterscheidungsmerkmal Icon und Tooltip beschreiben Farbe ist niemals das einzige Unterscheidungsmerkmal Icon und Tooltip beschreiben
den Status auch ohne Farbwahrnehmung eindeutig. Die vollständige Status-Mapping-Tabelle den Status auch ohne Farbwahrnehmung eindeutig. Die vollständige Status-Mapping-Tabelle
mit Tooltips ist in Abschnitt 19 beschrieben. mit Tooltips ist in Abschnitt 20 beschrieben.
- Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge - Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge
zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des
@@ -619,10 +637,31 @@ Das Panel enthält drei Bereiche:
- **Seitennavigation:** Über die Schaltflächen **„◀"** und **„▶"** (oder das Mausrad) - **Seitennavigation:** Über die Schaltflächen **„◀"** und **„▶"** (oder das Mausrad)
kann seitenweise geblättert werden. Die aktuelle Seitenzahl und Gesamtseitenzahl kann seitenweise geblättert werden. Die aktuelle Seitenzahl und Gesamtseitenzahl
werden angezeigt. werden angezeigt.
- **Fit-to-view:** Die Seite wird automatisch an die verfügbare Fläche angepasst - **Fit-to-Width:** Nach dem Laden wird die Seite automatisch an die verfügbare Breite
(preserveRatio=true). Keine Scrollbalken, keine manuelle Zoom-Einstellung. angepasst (preserveRatio=true).
- Das Rendering erfolgt direkt über Apache PDFBox bei 120 DPI. - Das Rendering erfolgt direkt über Apache PDFBox bei 120 DPI.
#### Zoom per Mausrad (Strg+Mausrad)
- **Strg + Mausrad nach oben/unten** zoomt die Vorschau herein bzw. heraus.
- Zoombereich: **10 % bis 500 %**, ca. 10 % je Mausrad-Rastpunkt.
- Nach dem ersten manuellen Zoom verlässt die Vorschau den Fit-to-Width-Modus.
Fit-to-Width wird erst wieder aktiv, wenn ein neues PDF geladen oder der
Fit-to-Width-Button explizit betätigt wird.
- Beim Laden eines neuen PDF wird der Zoom auf Fit-to-Width zurückgesetzt.
- Beim Zoomen bleibt die sichtbare Viewport-Mitte möglichst stabil.
- Trackpad-Gesten (sehr kleine Delta-Werte) werden intern akkumuliert, bis ein
vollständiger Zoomschritt erreicht ist.
- **Ohne Strg:** Mausrad scrollt die Seite normal (kein Zoom).
- ScrollEvents mit gedrückter Strg-Taste werden immer konsumiert, sodass kein
paralleles Scrollen im Hintergrund stattfindet.
#### Grab & Pan (Handcursor im Zoom-Modus)
Im vergrößerten Zustand (Zoom über Fit-to-Width) wechselt der Mauszeiger über
der Vorschau auf einen **Handcursor**. Durch Klicken und Ziehen (Drag) kann die
Ansicht verschoben werden. Im Fit-to-Width-Modus ist Pan nicht aktiv.
### KI-Begründung und Fehlertext ### KI-Begründung und Fehlertext
Der mittlere Bereich zeigt das KI-Reasoning des ausgewählten Eintrags. Der mittlere Bereich zeigt das KI-Reasoning des ausgewählten Eintrags.
@@ -710,7 +749,88 @@ nicht gezählt sie treten nach Laufabschluss nicht mehr auf.
--- ---
## 14. Bekannte Einschränkungen ## 14. Tab „Scheduler" (automatische Verarbeitungsläufe)
Der dritte Tab **„Scheduler"** ermöglicht den Betrieb eines optionalen, periodisch
ausgeführten automatischen Schedulers. Er startet Verarbeitungsläufe in einem
konfigurierten Intervall, ohne dass ein manueller Start erforderlich ist.
### Voraussetzung
Damit der Scheduler-Tab funktioniert, muss in der **gespeicherten** Konfigurationsdatei
`scheduler.enabled=true` und ein gültiges `scheduler.interval.seconds` (Integer >= 30)
eingetragen sein. Ungültige oder fehlende Werte werden im Tab als Fehler gemeldet; der
Scheduler-Start ist in diesem Fall nicht möglich.
### Start und Stop
- **„Scheduler starten"** Aktiviert den Scheduler. Der erste Lauf beginnt
**unmittelbar** nach dem Start (kein initiales Warten auf das Intervall).
- **„Scheduler stoppen"** Stoppt den Scheduler. Ein laufender Verarbeitungslauf wird
als Soft-Stop behandelt: die aktuell bearbeitete Datei wird fertig verarbeitet,
danach hält der Scheduler an.
Beide Buttons wechseln je nach Zustand ihre Sichtbarkeit: Nur der zum aktuellen
Zustand passende Button ist aktiv.
### Statusanzeige
Der Tab zeigt den aktuellen Scheduler-Zustand in Echtzeit (1-Sekunden-Takt):
| Zustand | Anzeige |
|---------|---------|
| `STOPPED` | Scheduler gestoppt |
| `STARTING` | Scheduler wird gestartet … |
| `RUNNING_IDLE` | Scheduler läuft nächster Lauf in `HH:MM:SS` |
| `RUNNING_BATCH_ACTIVE` | Scheduler läuft Verarbeitungslauf aktiv |
| `STOPPING_BATCH_ACTIVE` | Scheduler wird gestoppt Lauf läuft noch … |
Im Zustand `RUNNING_IDLE` zeigt der Tab einen Countdown bis zum nächsten automatischen
Verarbeitungslauf.
### Informationen zum letzten Lauf
Der Tab zeigt:
- **Letzter Lauf beendet:** Zeitpunkt des letzten abgeschlossenen Verarbeitungslaufs
(oder „–" wenn noch kein Lauf stattfand).
- **Zusammenfassung:** Anzahl erfolgreich, wiederholt, fehlgeschlagen und übersprungen
des letzten Laufs (falls verfügbar).
- **Letzter Fehler:** Fehlermeldung des letzten nicht erfolgreichen Scheduler-Laufs,
sofern vorhanden.
### Autostart-Fehler
Ist `scheduler.enabled=true` in der Konfiguration, versucht die GUI den Scheduler
beim Start automatisch zu aktivieren. Schlägt dies fehl (z. B. ungültige Konfiguration,
Intervall < 30 Sekunden), wird der Fehler im Tab angezeigt. Der Benutzer kann dann die
Konfiguration korrigieren und den Scheduler manuell starten.
### Warum sind manuelle Läufe während eines aktiven Schedulers gesperrt?
Manuelle Läufe (Tab „Verarbeitungslauf") sind während eines aktiven Schedulers
deaktiviert. Dadurch werden parallele Läufe auf dieselbe Datenmenge vermieden, die
zu inkonsistenten Datenbankzuständen führen könnten. Der Start-Button im Tab
„Verarbeitungslauf" ist während eines aktiven Schedulers deaktiviert und zeigt einen
erklärenden Tooltip.
### Warum ist Tab 1 „Konfiguration" während eines aktiven Schedulers gesperrt?
Um sicherzustellen, dass der Scheduler mit einer konsistenten Konfiguration läuft,
ist der Konfigurations-Editor während eines aktiven Schedulers gesperrt. Ein
Hinweisbanner erklärt die Sperre. Konfigurationsänderungen können nach dem Stoppen
des Schedulers vorgenommen werden.
### Schließen der Anwendung
Versucht der Benutzer das Fenster zu schließen oder die Anwendung über das
Tray-Menü zu beenden, während der Scheduler aktiv ist oder ein Lauf läuft, erscheint
ein Informationsdialog mit dem Hinweis, den Scheduler zunächst zu stoppen bzw. den
laufenden Verarbeitungslauf abzuwarten. Das Schließen wird blockiert, bis der Scheduler
gestoppt und kein Lauf mehr aktiv ist.
---
## 15. Bekannte Einschränkungen
| Einschränkung | Erläuterung | | Einschränkung | Erläuterung |
|---|---| |---|---|
@@ -718,13 +838,13 @@ nicht gezählt sie treten nach Laufabschluss nicht mehr auf.
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand | | Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird | | Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet | | GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. Die dauerhaften Ergebnisse sind im Verlauf-Tab (Abschnitt 16) einsehbar. | | Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. Die dauerhaften Ergebnisse sind im Verlauf-Tab (Abschnitt 17) einsehbar. |
| Einzelinstanz-Schutz | Wird die Anwendung ein zweites Mal gestartet, während bereits eine Instanz läuft (auch wenn diese im System-Tray minimiert ist), beendet sich die neue Instanz sofort ohne Hinweisfenster | | Einzelinstanz-Schutz | Wird die Anwendung ein zweites Mal gestartet, während bereits eine Instanz läuft (auch wenn diese im System-Tray minimiert ist), beendet sich die neue Instanz sofort ohne Hinweisfenster |
| Prompt-Editor: kein automatisches Reload | Wird die Prompt-Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht. Beim Speichern gilt Last-write-wins. | | Prompt-Editor: kein automatisches Reload | Wird die Prompt-Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht. Beim Speichern gilt Last-write-wins. |
--- ---
## 15. System-Tray ## 16. System-Tray
Wird das Hauptfenster über das Schließen-Symbol (oder Alt+F4) geschlossen, ohne dass Wird das Hauptfenster über das Schließen-Symbol (oder Alt+F4) geschlossen, ohne dass
ungespeicherte Änderungen oder ein aktiver Verarbeitungslauf vorliegen, **minimiert ungespeicherte Änderungen oder ein aktiver Verarbeitungslauf vorliegen, **minimiert
@@ -752,7 +872,7 @@ Ein **Doppelklick** auf das Tray-Icon hat denselben Effekt wie „Öffnen".
--- ---
## 16. Tab „Verlauf" (Historien-Tab) ## 17. Tab „Verlauf" (Historien-Tab)
Der dritte Tab **„Verlauf"** zeigt alle jemals verarbeiteten Dokumente mit Status, Der dritte Tab **„Verlauf"** zeigt alle jemals verarbeiteten Dokumente mit Status,
Dateinamen und Verarbeitungsdetails. Die Daten stammen direkt aus der SQLite-Datenbank, Dateinamen und Verarbeitungsdetails. Die Daten stammen direkt aus der SQLite-Datenbank,
@@ -771,7 +891,7 @@ Die Tabelle zeigt folgende Spalten:
| Spalte | Inhalt | | Spalte | Inhalt |
|--------|--------| |--------|--------|
| Status-Icon | Symbol und Farbe gemäß Status-Mapping-Tabelle (Abschnitt 19) | | Status-Icon | Symbol und Farbe gemäß Status-Mapping-Tabelle (Abschnitt 20) |
| Quelldateiname | Ursprünglicher Dateiname der PDF-Datei | | Quelldateiname | Ursprünglicher Dateiname der PDF-Datei |
| Zieldateiname | Zuletzt vergebener Dateiname nach Umbenennung | | Zieldateiname | Zuletzt vergebener Dateiname nach Umbenennung |
| Quellpfad | Letzter bekannter Quellordner | | Quellpfad | Letzter bekannter Quellordner |
@@ -792,11 +912,38 @@ Die Tabelle zeigt folgende Spalten:
Über dem Tab befinden sich drei Bedienelemente: Über dem Tab befinden sich drei Bedienelemente:
- **Freitextsuche** filtert über Quelldateiname und Zieldateiname, case-insensitiv - **Freitextsuche** filtert über Quelldateiname und Zieldateiname, case-insensitiv
- **Status-Filter** ComboBox zur Auswahl eines bestimmten Status oder „Alle" - **Status-Filter** ComboBox zur Auswahl eines bestimmten Status oder „Alle Status"
- **„Aktualisieren"** lädt die Liste neu aus der Datenbank (kein automatisches Echtzeit-Tailing) - **„Suchen"** startet die Suche sofort; alternativ die Enter-Taste im Suchfeld
Die Suche erfolgt datenbanksseitig; Sonderzeichen in der Sucheingabe werden korrekt behandelt. Die Suche erfolgt datenbanksseitig; Sonderzeichen in der Sucheingabe werden korrekt behandelt.
#### Live-Suche
Die Freitextsuche reagiert **live** auf Tastatureingaben: 300 ms nach dem letzten
Tastendruck startet die Suche automatisch auf einem Hintergrund-Thread.
Der Such-Button und die Enter-Taste starten die Suche sofort ohne Verzögerung.
Nach jeder neuen Suchanfrage wird die Tabellenauswahl vollständig geleert;
Detailbereich und Aktionsbuttons werden zurückgesetzt. Ein leeres Suchfeld zeigt
alle Einträge (bis Limit 500).
### Mehrfachauswahl
Die Verlauf-Tabelle unterstützt **Mehrfachauswahl**:
| Geste | Wirkung |
|---|---|
| **Klick** | Einzelauswahl |
| **Strg+Klick** | Einzelnen Eintrag zur Auswahl hinzufügen oder entfernen |
| **Shift+Klick** | Bereich vom letzten zur aktuellen Zeile auswählen |
| **Strg+A** | Alle sichtbaren Einträge auswählen (**nur wenn die Tabelle den Fokus hat**) |
> **Hinweis:** Liegt der Fokus im Suchfeld, wirkt Strg+A als normale Textselektion
> im Suchfeld und selektiert keine Tabellenzeilen.
Bei Mehrfachauswahl zeigt der **Detailbereich** den Platzhaltertext
„X Einträge ausgewählt." (statt Dokumentdetails).
### Detailbereich ### Detailbereich
Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke: Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
@@ -813,40 +960,104 @@ Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
|--------|--------| |--------|--------|
| # | Versuchsnummer | | # | Versuchsnummer |
| Datum | Endzeitpunkt des Versuchs | | Datum | Endzeitpunkt des Versuchs |
| Status | Ergebnisstatus des Versuchs | | Status | Ergebnisstatus des Versuchs (lesbarer Anzeigetext, kein Enum-Rohname) |
| Provider | Verwendeter KI-Provider | | Provider | Verwendeter KI-Provider |
| Modell | Verwendetes Sprachmodell | | Modell | Verwendetes Sprachmodell |
| Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname | | Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname |
**KI-Begründung:** Das `ai_reasoning` des ausgewählten Versuchs als nicht editierbarer Text. **KI-Begründung / Fehlerursache:**
Das `ai_reasoning` des zuletzt ausgewählten Versuchs als nicht editierbarer Text.
Ist kein Reasoning gespeichert, erscheint ein gedimmter Platzhaltertext
„Keine KI-Begründung für diesen Versuch gespeichert."
Bei Einträgen mit Status `FAILED_FINAL`, `FAILED_RETRYABLE` oder
`SKIPPED_FINAL_FAILURE` wird zusätzlich die **Fehlerursache** des letzten
fehlgeschlagenen Versuchs angezeigt. Liegt keine Fehlerursache vor (z. B. ältere
Einträge), erscheint ebenfalls ein Platzhaltertext.
### Aktionen ### Aktionen
Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung: Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung.
**Beide Aktionen unterstützen Mehrfachauswahl** (≥ 1 Eintrag):
**„Status zurücksetzen"** **„Status zurücksetzen"**
Setzt den Status des ausgewählten Dokuments auf „Wartet auf Verarbeitung" zurück, Setzt den Status der ausgewählten Dokumente auf „Wartet auf Verarbeitung" zurück,
sodass es beim nächsten Verarbeitungslauf automatisch erneut verarbeitet wird. sodass sie beim nächsten Verarbeitungslauf automatisch erneut verarbeitet werden.
Die Versuchshistorie bleibt vollständig erhalten kein Versuch wird gelöscht. Die Versuchshistorie bleibt vollständig erhalten kein Versuch wird gelöscht.
Vor der Aktion erscheint ein Bestätigungsdialog. Vor der Aktion erscheint ein Bestätigungsdialog: „X Einträge zurücksetzen?"
Bei Mehrfachauswahl werden Einträge einzeln zurückgesetzt. Nach Abschluss erscheint
eine kompakte Zusammenfassung „X von Y erfolgreich verarbeitet." Detaillierte
Einzelfehler werden geloggt.
Wann sinnvoll: wenn die Ursache eines Fehlers behoben wurde (z. B. OCR nachträglich Wann sinnvoll: wenn die Ursache eines Fehlers behoben wurde (z. B. OCR nachträglich
durchgeführt, Passwortschutz entfernt) und das Dokument erneut verarbeitet werden soll. durchgeführt, Passwortschutz entfernt) und das Dokument erneut verarbeitet werden soll.
**„Eintrag löschen"** **„Eintrag löschen"**
Löscht den Stammsatz und alle Verarbeitungsversuche des ausgewählten Dokuments Löscht die Stammsätze und alle Verarbeitungsversuche der ausgewählten Dokumente
vollständig aus der Datenbank. Diese Aktion ist **nicht rückgängig zu machen**. vollständig aus der Datenbank. Diese Aktion ist **nicht rückgängig zu machen**.
Vor der Aktion erscheint ein Bestätigungsdialog mit einem ausdrücklichen Hinweis Vor der Aktion erscheint ein Bestätigungsdialog: „X Einträge unwiderruflich löschen?"
auf die Unwiderruflichkeit.
Bei Mehrfachauswahl gilt dieselbe Partial-Success-Logik wie beim Zurücksetzen.
**Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert. **Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert.
Ein Hinweis „Aktion während Verarbeitungslauf nicht möglich." wird angezeigt. Nach Laufende werden die Buttons automatisch reaktiviert, sofern eine Auswahl besteht
ohne dass der Benutzer die Auswahl erneuern muss.
--- ---
## 17. Tab „Prompt" (Prompt-Editor) ## 17a. Neue Datenbank anlegen
Über **Datenbank → Neue Datenbank anlegen...** in der Menüleiste kann eine neue,
leere SQLite-Datenbank erstellt und sofort als aktive Datenbank der Anwendung
gesetzt werden ohne Neustart.
### Voraussetzung
Der Menüpunkt ist nur aktiv, wenn kein Verarbeitungslauf läuft.
### Ablauf
1. Ein Dateidialog öffnet sich (Filter: `*.sqlite` und `*.db`). Neue Zieldatei
wählen oder eingeben.
2. Die Anwendung prüft, ob die gewählte Datei identisch mit der aktuell aktiven
Datenbank ist (normalisierter, case-insensitiver Pfadvergleich). Bei
Übereinstimmung erscheint eine Fehlermeldung, kein Überschreiben.
3. Existiert die gewählte Datei bereits (andere als aktive DB): Bestätigungsdialog
„Die Datei existiert bereits. Überschreiben?"
4. Die neue DB wird als temporäre Datei im Zielverzeichnis erzeugt. Flyway
führt alle Migrationsskripte auf den neuesten Schema-Stand aus.
5. Verbindungstest: Verbindung öffnen, Flyway-History prüfen, Leseabfrage prüfen.
6. Nach erfolgreichem Test: atomarer Move zur Zieldatei
(`ATOMIC_MOVE + REPLACE_EXISTING`). Schlägt dies fehl, bricht der Vorgang
mit einer klaren Fehlermeldung ab.
7. Die aktive Datenbankverbindung wechselt zur neuen DB.
8. Der Verlauf-Tab lädt neu: „Noch keine Verarbeitungen vorhanden."
9. Die Statuszeile aktualisiert den DB-Pfad.
10. Die Konfiguration wird als geändert markiert (Dirty-State im Konfig-Tab).
11. Im Meldungsbereich erscheint der Hinweis:
„Neue Datenbank ist aktiv. Konfiguration speichern, damit diese Datenbank
beim nächsten Start verwendet wird."
### Fehlerfall
Schlägt ein Schritt fehl, bleibt die bisherige Datenbank vollständig unverändert
in Betrieb. Die temporäre Datei wird gelöscht. Ein Fehlerdialog erscheint mit
einer konkreten Meldung.
### Wichtiger Hinweis
**Die Konfigurationsdatei wird durch den DB-Wechsel nicht automatisch gespeichert.**
Damit die neue Datenbank beim nächsten Start der Anwendung verwendet wird, muss
die Konfiguration explizit über „Speichern" oder „Speichern unter" gesichert werden.
Der Dirty-State im Konfig-Tab und der Hinweis im Meldungsbereich erinnern daran.
---
## 18. Tab „Prompt" (Prompt-Editor)
Der vierte Tab **„Prompt"** ermöglicht das Lesen, Bearbeiten und Speichern der Der vierte Tab **„Prompt"** ermöglicht das Lesen, Bearbeiten und Speichern der
KI-Prompt-Datei direkt in der GUI ohne externen Editor. KI-Prompt-Datei direkt in der GUI ohne externen Editor.
@@ -891,7 +1102,7 @@ Ein Klick legt eine Prompt-Datei mit dem deutschen Standard-Template an
--- ---
## 18. Statuszeile ## 19. Statuszeile
Am unteren Rand des Hauptfensters ist permanent eine **Statuszeile** (`GuiStatusBar`) Am unteren Rand des Hauptfensters ist permanent eine **Statuszeile** (`GuiStatusBar`)
sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente: sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
@@ -912,7 +1123,7 @@ sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
--- ---
## 19. Fehlerstatus Bedeutung und Unterscheidung ## 20. Fehlerstatus Bedeutung und Unterscheidung
Zwei Fehlerstatus werden in der GUI klar unterschieden. Die Unterscheidung ist wichtig, Zwei Fehlerstatus werden in der GUI klar unterschieden. Die Unterscheidung ist wichtig,
um zu entscheiden, ob eine erneute Verarbeitung sinnvoll ist. um zu entscheiden, ob eine erneute Verarbeitung sinnvoll ist.
@@ -942,7 +1153,7 @@ werden nicht mehr unternommen.
**Was passiert:** Das Dokument wird in späteren Läufen übersprungen. **Was passiert:** Das Dokument wird in späteren Läufen übersprungen.
**Mögliche Abhilfe:** Wenn die Ursache behoben wurde (z. B. OCR wurde nachträglich **Mögliche Abhilfe:** Wenn die Ursache behoben wurde (z. B. OCR wurde nachträglich
durchgeführt), kann der Status im **Verlauf-Tab** (Abschnitt 16) manuell zurückgesetzt durchgeführt), kann der Status im **Verlauf-Tab** (Abschnitt 17) manuell zurückgesetzt
werden. Das Dokument wird dann beim nächsten Lauf erneut verarbeitet. Alternativ kann werden. Das Dokument wird dann beim nächsten Lauf erneut verarbeitet. Alternativ kann
der Eintrag vollständig gelöscht werden, damit die Datei als neu erkannt wird. der Eintrag vollständig gelöscht werden, damit die Datei als neu erkannt wird.
@@ -966,7 +1177,7 @@ beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
--- ---
## 20. Tooltips ## 21. Tooltips
Auf den meisten interaktiven Elementen der GUI sind Tooltips gesetzt, die beim Hover über Auf den meisten interaktiven Elementen der GUI sind Tooltips gesetzt, die beim Hover über
ein Element erscheinen. Sie erklären kurz den Zweck des Elements. ein Element erscheinen. Sie erklären kurz den Zweck des Elements.
@@ -978,7 +1189,7 @@ Tooltips sind unter anderem vorhanden auf:
- **Toolbar-Buttons** Neu, Öffnen, Speichern, Speichern unter, Validieren, - **Toolbar-Buttons** Neu, Öffnen, Speichern, Speichern unter, Validieren,
Technische Tests ausführen Technische Tests ausführen
- **Status-Icons** im Verarbeitungslauf-Tab Text gemäß Status-Mapping-Tabelle - **Status-Icons** im Verarbeitungslauf-Tab Text gemäß Status-Mapping-Tabelle
(Abschnitt 19) (Abschnitt 20)
- **Buttons „Dateiname übernehmen"** und **„Zurücksetzen auf KI-Vorschlag"** im - **Buttons „Dateiname übernehmen"** und **„Zurücksetzen auf KI-Vorschlag"** im
Dateiname-Editor (Abschnitt 13b) Dateiname-Editor (Abschnitt 13b)
+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
@@ -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) {
}
}
@@ -40,6 +40,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort; import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator; import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
@@ -57,6 +58,7 @@ import javafx.scene.Node;
import javafx.scene.Parent; import javafx.scene.Parent;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType; import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox; import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
@@ -122,6 +124,12 @@ import javafx.stage.Window;
* Thread via {@code Platform.runLater}. * Thread via {@code Platform.runLater}.
*/ */
public final class GuiConfigurationEditorWorkspace { public final class GuiConfigurationEditorWorkspace {
private static final String NO_PROMPT_PATH_MSG = "Kein Prompt-Pfad konfiguriert.";
private static final String OPERATION_VALIDATE = "Validierung";
private static final String PROPERTIES_FILTER_EXT = "*.properties";
private static final String PROPERTIES_FILTER_DESC = "Properties-Dateien";
private static final Logger LOG = LogManager.getLogger(GuiConfigurationEditorWorkspace.class); private static final Logger LOG = LogManager.getLogger(GuiConfigurationEditorWorkspace.class);
private static final String WELCOME_TEXT = private static final String WELCOME_TEXT =
@@ -422,6 +430,40 @@ public final class GuiConfigurationEditorWorkspace {
*/ */
private final GuiPromptEditorPortFactory promptEditorPortFactory; private final GuiPromptEditorPortFactory promptEditorPortFactory;
/**
* Bridge zur DB-Anlage- und Wechsellogik. Wird vom Menüpunkt
* „Datenbank → Neue Datenbank anlegen…" ausgelöst.
*/
private final GuiCreateNewDatabasePort createNewDatabasePort;
/**
* Callback, der nach jedem erfolgreichen Datei-Öffnen auf dem Hintergrund-Thread
* aufgerufen wird, um den Bootstrap-seitigen Anwendungskontext und den Scheduler
* zu initialisieren.
*/
private final GuiApplicationContextInitializer applicationContextInitializer;
/**
* Aktiver DB-Busy-Zustand während einer laufenden Datenbank-Anlage. Solange
* dieser Zustand aktiv ist, sind alle DB-lesenden und DB-schreibenden Aktionen
* der GUI gesperrt (vgl. {@link #applyDbBusyLock()}).
* <p>
* Als JavaFX-Property realisiert, damit die Menüleiste den Zustand direkt
* über {@code disableProperty().bind(...)} auswerten kann.
*/
private final javafx.beans.property.SimpleBooleanProperty dbBusyForDatabaseCreation =
new javafx.beans.property.SimpleBooleanProperty(false);
/**
* Hintergrund-Worker-Thread für die DB-Anlage; einzel-threadig, damit nicht
* mehrere DB-Anlagen gleichzeitig laufen können.
*/
private final ExecutorService createNewDatabaseExecutor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "gui-create-new-database");
thread.setDaemon(true);
return thread;
});
/** /**
* Second main tab of the window that drives the live processing-run view. Created * Second main tab of the window that drives the live processing-run view. Created
* during workspace construction and wired into the shared {@link #tabPane} alongside * during workspace construction and wired into the shared {@link #tabPane} alongside
@@ -430,25 +472,37 @@ public final class GuiConfigurationEditorWorkspace {
private final GuiBatchRunTab batchRunTab; private final GuiBatchRunTab batchRunTab;
/** /**
* Dritter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion * Dritter Haupt-Tab: Scheduler-Steuerung. Wird während der Workspace-Konstruktion
* erstellt und in den {@link #tabPane} eingehängt.
*/
private final GuiSchedulerTab schedulerTab;
/**
* Vierter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion
* erstellt und in den {@link #tabPane} eingehängt. * erstellt und in den {@link #tabPane} eingehängt.
*/ */
private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab; private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab;
/** /**
* Vierter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt * Fünfter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
* und in den {@link #tabPane} eingehängt. * und in den {@link #tabPane} eingehängt.
*/ */
private final GuiPromptEditorTab promptEditorTab; private final GuiPromptEditorTab promptEditorTab;
/** /**
* Hint banner shown at the top of the configuration tab while a processing run is * Hint banner shown at the top of the configuration tab while a processing run or
* active. Visible + managed state are flipped from the batch run tab's listener when * the automatic scheduler is active. Visible + managed state are controlled by
* the running flag toggles. * {@link #applyConfigTabLockState()}.
*/ */
final Label configurationLockBanner = new Label( final Label configurationLockBanner = new Label(
"Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar"); "Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar");
/**
* {@code true} while the automatic scheduler is in any non-{@code STOPPED} state.
* Updated by {@link #updateLockState(SchedulerStatus)} from the 1 Hz refresh timeline.
*/
private boolean schedulerLockActive = false;
/** /**
* Reference to the configuration tab so the running-state listener can disable its * Reference to the configuration tab so the running-state listener can disable its
* content while a batch run is active. * content while a batch run is active.
@@ -513,6 +567,8 @@ public final class GuiConfigurationEditorWorkspace {
this.manualFileCopyPort = effectiveContext.manualFileCopyPort(); this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort(); this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory(); this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory();
this.createNewDatabasePort = effectiveContext.createNewDatabasePort();
this.applicationContextInitializer = effectiveContext.applicationContextInitializer();
this.batchRunTab = new GuiBatchRunTab( this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher, () -> this.batchRunLauncher,
() -> this.miniRunLauncher, () -> this.miniRunLauncher,
@@ -524,7 +580,12 @@ public final class GuiConfigurationEditorWorkspace {
() -> this.manualFileCopyPort, () -> this.manualFileCopyPort,
() -> this.historicalDocumentContextPort, () -> this.historicalDocumentContextPort,
this::editorSourceFolder, this::editorSourceFolder,
this::editorTargetFolder); this::editorTargetFolder,
effectiveContext.configurationFileLockPort());
this.schedulerTab = new GuiSchedulerTab(
effectiveContext.schedulerControlUseCase(),
() -> editorState.isDirty());
this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab( this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab(
effectiveContext.historyOverviewPort(), effectiveContext.historyOverviewPort(),
@@ -534,6 +595,13 @@ public final class GuiConfigurationEditorWorkspace {
this.batchRunTab::isRunning, this.batchRunTab::isRunning,
this::loadedConfigurationPath); this::loadedConfigurationPath);
// Aktionsbuttons im Verlauf-Tab reaktivieren, sobald der Lauf beendet ist
this.batchRunTab.runningProperty().addListener((obs, wasRunning, running) -> {
if (!running) {
Platform.runLater(this.historyTab::notifyRunEnded);
}
});
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile(); String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
int maxTitleLength; int maxTitleLength;
try { try {
@@ -647,23 +715,41 @@ public final class GuiConfigurationEditorWorkspace {
} }
/** /**
* Applies the "batch run active" UI lock state to the configuration tab and the * Applies the "batch run active" UI lock state to the configuration tab.
* action bar.
* <p> * <p>
* While a run is active the configuration editor is made non-interactive, the lock * Delegates to {@link #applyConfigTabLockState()} so that both the batch-run lock
* banner is shown at the top of Tab 1, and the main action buttons (Neu, Öffnen, * and the scheduler lock are evaluated together. Called whenever the batch-run
* Speichern, Speichern unter) are disabled. When the run ends, the locks are * running state changes.
* released and the editor returns to its normal state.
*/ */
void applyBatchRunLockState() { void applyBatchRunLockState() {
boolean running = batchRunTab != null && batchRunTab.isRunning(); applyConfigTabLockState();
configurationLockBanner.setVisible(running); }
configurationLockBanner.setManaged(running);
sectionsBox.setDisable(running); /**
newButton.setDisable(running); * Evaluates the combined lock state (batch run active <em>or</em> scheduler active)
openButton.setDisable(running); * and applies it to the configuration tab.
saveButton.setDisable(running); * <p>
saveAsButton.setDisable(running); * When either source is locked the banner is shown, all input sections are disabled
* and the action buttons (Neu, Öffnen, Speichern, Speichern unter) are disabled.
* The banner text describes the dominant lock source.
*/
private void applyConfigTabLockState() {
boolean batchRunning = batchRunTab != null && batchRunTab.isRunning();
boolean locked = batchRunning || schedulerLockActive;
String bannerText = schedulerLockActive
? "⚠ Konfiguration gesperrt Scheduler läuft (oder Lauf aktiv)."
+ " Scheduler beenden bzw. Lauf abwarten um Änderungen vorzunehmen."
: "Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar";
configurationLockBanner.setText(bannerText);
configurationLockBanner.setVisible(locked);
configurationLockBanner.setManaged(locked);
sectionsBox.setDisable(locked);
newButton.setDisable(locked);
openButton.setDisable(locked);
saveButton.setDisable(locked);
saveAsButton.setDisable(locked);
} }
/** /**
@@ -913,7 +999,7 @@ public final class GuiConfigurationEditorWorkspace {
Window owner = root.getScene() == null ? null : root.getScene().getWindow(); Window owner = root.getScene() == null ? null : root.getScene().getWindow();
FileChooser fileChooser = new FileChooser(); FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Konfiguration öffnen"); fileChooser.setTitle("Konfiguration öffnen");
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Properties-Dateien", "*.properties")); fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(PROPERTIES_FILTER_DESC, PROPERTIES_FILTER_EXT));
if (owner != null && editorState.hasLoadedFileSnapshot()) { if (owner != null && editorState.hasLoadedFileSnapshot()) {
Path currentPath = editorState.loadedFileSnapshot().orElseThrow().filePath(); Path currentPath = editorState.loadedFileSnapshot().orElseThrow().filePath();
Path parent = currentPath.getParent(); Path parent = currentPath.getParent();
@@ -946,7 +1032,13 @@ public final class GuiConfigurationEditorWorkspace {
GuiConfigurationEditorState loadedState = configurationFileLoader.load(configFilePath); GuiConfigurationEditorState loadedState = configurationFileLoader.load(configFilePath);
// Speichern des Pfads als letzte geladene Konfiguration // Speichern des Pfads als letzte geladene Konfiguration
saveLastConfigurationPath(configFilePath); saveLastConfigurationPath(configFilePath);
Platform.runLater(() -> applyEditorState(loadedState)); // Anwendungskontext und Scheduler initialisieren; Ergebnis auf dem FX-Thread auswerten.
GuiApplicationContextInitializer.InitResult initResult =
applicationContextInitializer.initialize(configFilePath);
Platform.runLater(() -> {
applyEditorState(loadedState);
initResult.schedulerControlUseCase().ifPresent(schedulerTab::onSchedulerAvailable);
});
} catch (Exception exception) { } catch (Exception exception) {
Platform.runLater(() -> showError("Konfiguration konnte nicht geladen werden: " Platform.runLater(() -> showError("Konfiguration konnte nicht geladen werden: "
+ safeMessage(exception))); + safeMessage(exception)));
@@ -983,7 +1075,7 @@ public final class GuiConfigurationEditorWorkspace {
FileChooser fileChooser = saveFileChooserFactory.get(); FileChooser fileChooser = saveFileChooserFactory.get();
fileChooser.setTitle("Konfiguration speichern"); fileChooser.setTitle("Konfiguration speichern");
fileChooser.getExtensionFilters().add( fileChooser.getExtensionFilters().add(
new FileChooser.ExtensionFilter("Properties-Dateien", "*.properties")); new FileChooser.ExtensionFilter(PROPERTIES_FILTER_DESC, PROPERTIES_FILTER_EXT));
// Propose the default path relative to the working directory. // Propose the default path relative to the working directory.
Path proposedDir = DEFAULT_SAVE_PATH.getParent(); Path proposedDir = DEFAULT_SAVE_PATH.getParent();
@@ -1011,6 +1103,291 @@ public final class GuiConfigurationEditorWorkspace {
checkExistsAndSave(targetPath, () -> { }); checkExistsAndSave(targetPath, () -> { });
} }
/**
* Liefert {@code true}, wenn aktuell gerade eine Datenbank-Anlage läuft und der
* Menüpunkt „Datenbank → Neue Datenbank anlegen…" daher gesperrt ist.
*
* @return aktueller DB-Busy-Zustand
*/
public boolean isDbBusyForDatabaseCreation() {
return dbBusyForDatabaseCreation.get();
}
/**
* Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty} für den
* DB-Busy-Zustand. Wird von der Menüleiste genutzt, um den Menüpunkt
* „Neue Datenbank anlegen…" während einer laufenden Anlage automatisch zu
* deaktivieren.
*
* @return read-only Property; nie {@code null}
*/
public javafx.beans.property.BooleanProperty dbBusyForDatabaseCreationProperty() {
return dbBusyForDatabaseCreation;
}
/**
* Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty}, die den
* Lauf-aktiv-Zustand des Verarbeitungslauf-Tabs spiegelt. Wird von der
* Menüleiste genutzt, um den Menüpunkt „Neue Datenbank anlegen…" während
* eines laufenden Verarbeitungslaufs zu deaktivieren.
*
* @return read-only Property; nie {@code null}
*/
public javafx.beans.property.ReadOnlyBooleanProperty batchRunRunningProperty() {
return batchRunTab.runningProperty();
}
/**
* Leitet einen aktuellen Scheduler-Status-Snapshot an alle betroffenen Tabs weiter.
* <p>
* Wird von der zentralen Status-Refresh-Timeline (1 Hz) auf dem JavaFX Application
* Thread aufgerufen. Die Methode delegiert an:
* <ul>
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab#updateSchedulerState}
* Schaltflächen-Zustand im Verarbeitungslauf-Tab</li>
* <li>{@link GuiSchedulerTab#updateStatus} Statusanzeige im Scheduler-Tab</li>
* <li>{@link #updateLockState} Banner und Speichern-Button im Konfig-Tab</li>
* </ul>
*
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
*/
public void onSchedulerStatusRefresh(SchedulerStatus status) {
batchRunTab.updateSchedulerState(status);
schedulerTab.updateStatus(status);
updateLockState(status);
}
/**
* Liest den aktuellen Scheduler-Status vom verdrahteten Use Case und reicht ihn an
* alle betroffenen Tabs weiter.
* <p>
* Im Gegensatz zu einem direkten Lesen am unveränderlichen
* {@code GuiStartupContext} berücksichtigt diese Methode den nach einem
* erfolgreichen Datei-Öffnen erst zur Laufzeit verdrahteten Use Case
* (Auto-Load der zuletzt geladenen Konfiguration oder manuelles Öffnen).
* Ist kein Use Case verdrahtet, ist der Aufruf ein No-op.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void refreshSchedulerStatus() {
schedulerTab.currentSchedulerUseCase()
.ifPresent(uc -> onSchedulerStatusRefresh(uc.getStatus()));
}
/**
* Prüft, ob der aktuell verdrahtete Scheduler-Use-Case in einem aktiven
* Zustand (Zustand != {@code STOPPED}) ist.
* <p>
* Liest den Use Case dynamisch aus dem {@link GuiSchedulerTab}, damit auch
* der nach erfolgreichem Datei-Öffnen erst zur Laufzeit verdrahtete Use Case
* erfasst wird. Ist kein Use Case verdrahtet, wird {@code false} zurückgegeben.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @return {@code true}, wenn ein Use Case verdrahtet ist und sein Zustand
* als aktiv gilt; sonst {@code false}
*/
public boolean isSchedulerActive() {
return schedulerTab.currentSchedulerUseCase()
.map(uc -> uc.getStatus().state().isActive())
.orElse(false);
}
/**
* Aktualisiert den Sperr-Zustand des Konfig-Tabs anhand des aktuellen Scheduler-Status.
* <p>
* Setzt {@link #schedulerLockActive} und ruft {@link #applyConfigTabLockState()} auf,
* sodass Banner, Eingabefelder und Aktionsbuttons des Konfig-Tabs sofort in den
* korrekten Zustand versetzt werden.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
*/
public void updateLockState(SchedulerStatus status) {
schedulerLockActive = status.state().isActive();
applyConfigTabLockState();
}
/**
* Behandelt die Aktion „Datenbank → Neue Datenbank anlegen…".
* <p>
* Öffnet einen FileChooser (Filter {@code *.db}, {@code *.sqlite}), prüft den Zielpfad
* gegen die aktive Datenbank, holt ggf. eine Überschreib-Bestätigung ein und
* delegiert die eigentliche Anlage an
* {@link GuiCreateNewDatabasePort#createNewDatabase(Path)} auf einem
* Hintergrund-Worker-Thread.
* <p>
* Während der Ausführung ist die GUI in einem DB-Busy-Zustand: alle
* DB-lesenden und DB-schreibenden Aktionen sind deaktiviert. Der Zustand
* wird nach Erfolg oder Fehler zuverlässig zurückgesetzt.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void requestCreateNewDatabase() {
if (dbBusyForDatabaseCreation.get()) {
LOG.debug("GUI-Editor: Anlage einer neuen Datenbank ist bereits in Arbeit Klick ignoriert.");
return;
}
if (batchRunTab != null && batchRunTab.isRunning()) {
showError("Während eines Verarbeitungslaufs kann keine neue Datenbank angelegt werden.");
return;
}
Window owner = root.getScene() == null ? null : root.getScene().getWindow();
FileChooser fileChooser = saveFileChooserFactory.get();
fileChooser.setTitle("Neue Datenbank anlegen");
fileChooser.getExtensionFilters().add(
new FileChooser.ExtensionFilter("SQLite-Datenbank (*.db, *.sqlite)", "*.db", "*.sqlite"));
// Vorschlagsverzeichnis: SQLite-Pfad aus der aktuellen Konfiguration, sofern gesetzt
String currentSqlite = editorState.values().sqliteFile();
String activeExtension = resolveDbExtension(currentSqlite);
fileChooser.setInitialFileName("neue-datenbank" + activeExtension);
if (currentSqlite != null && !currentSqlite.isBlank()) {
try {
Path proposedDir = Path.of(currentSqlite).toAbsolutePath().getParent();
if (proposedDir != null && proposedDir.toFile().isDirectory()) {
fileChooser.setInitialDirectory(proposedDir.toFile());
}
} catch (Exception ignore) {
// bei ungültigem Pfad: kein Vorschlagsverzeichnis
}
}
File selectedFile;
try {
selectedFile = saveDialogFunction.apply(fileChooser, owner);
} catch (UnsupportedOperationException e) {
LOG.debug("GUI-Editor: Datenbank-Speichern-Dialog nicht verfügbar (headless).");
return;
}
if (selectedFile == null) {
return;
}
Path requestedTarget = selectedFile.toPath().toAbsolutePath().normalize();
// Bestätigungsdialog wenn Datei bereits existiert (egal ob fremd oder aktive DB —
// die endgültige Sicherheitsprüfung gegen die aktive DB übernimmt der Use-Case).
if (java.nio.file.Files.exists(requestedTarget)) {
ButtonType ueberschreiben = new ButtonType("Überschreiben", ButtonBar.ButtonData.OK_DONE);
ButtonType abbrechen = new ButtonType("Abbrechen", ButtonBar.ButtonData.CANCEL_CLOSE);
Optional<ButtonType> choice = showConfirmation(
"Datei überschreiben?",
"Die Datei existiert bereits:\n" + requestedTarget
+ "\n\nDie vorhandene Datei wird durch eine neue, leere SQLite-Datenbank ersetzt.\nFortfahren?",
abbrechen,
ueberschreiben);
if (choice.isEmpty() || !choice.get().equals(ueberschreiben)) {
LOG.info("GUI-Editor: Anlage einer neuen Datenbank vom Benutzer abgebrochen.");
return;
}
}
startCreateNewDatabaseWorker(requestedTarget);
}
/**
* Aktiviert die DB-Busy-Sperre auf der Oberfläche und reicht den eigentlichen
* Aufruf des {@link GuiCreateNewDatabasePort} an einen Daemon-Worker-Thread weiter.
*
* @param targetFile der bereits geprüfte Zielpfad; nie {@code null}
*/
private void startCreateNewDatabaseWorker(Path targetFile) {
dbBusyForDatabaseCreation.set(true);
applyDbBusyLock();
showStatusMessage("Neue SQLite-Datenbank wird angelegt …");
createNewDatabaseExecutor.submit(() -> {
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
.CreateNewDatabaseResult result;
try {
result = createNewDatabasePort.createNewDatabase(loadedConfigurationPath(), targetFile);
} catch (RuntimeException e) {
LOG.error("GUI-Editor: Unerwarteter Fehler beim Anlegen der neuen Datenbank: {}",
e.getMessage(), e);
Platform.runLater(() -> {
dbBusyForDatabaseCreation.set(false);
applyDbBusyLock();
showError("Neue Datenbank konnte nicht angelegt werden: " + safeMessage(e));
});
return;
}
Platform.runLater(() -> handleCreateNewDatabaseResult(targetFile, result));
});
}
/**
* Übersetzt das Ergebnis des Use-Cases in die UI-Reaktion: Dirty-State,
* Statuszeile, Verlauf-Reload, Hinweismeldung oder Fehlerdialog.
*
* @param targetFile der vom Benutzer gewählte Zielpfad
* @param result das Ergebnis des Use-Cases
*/
private void handleCreateNewDatabaseResult(
Path targetFile,
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
.CreateNewDatabaseResult result) {
try {
switch (result) {
case de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Success success -> {
Path effectiveTarget = success.targetFile();
LOG.info("GUI-Editor: Neue Datenbank ist aktiv: {}", effectiveTarget);
// Konfigurationsmodell aktualisieren → Dirty-State
GuiConfigurationValues updated = editorState.values()
.withSqliteFile(effectiveTarget.toString());
updateValues(updated);
// Verlauf-Tab neu laden, damit die neue (leere) DB sichtbar wird
if (historyTab != null) {
historyTab.reloadAfterDatabaseSwitch();
}
// Hinweismeldung im zentralen Meldungsbereich
pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.INFO,
"Neue Datenbank ist aktiv. Konfiguration speichern, damit "
+ "diese DB beim nächsten Start verwendet wird.",
"Datenbank-Anlage"));
refreshAfterValidation();
showStatusMessage("Neue Datenbank ist aktiv: " + effectiveTarget);
}
case de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.SameAsActiveDatabase same -> {
LOG.warn("GUI-Editor: Anlage abgelehnt Zielpfad ist die aktuell aktive DB: {}",
same.targetFile());
showError("Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei. "
+ "Bitte einen anderen Pfad wählen.");
}
case de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed failure -> {
LOG.error("GUI-Editor: Anlage fehlgeschlagen ({}): {}",
failure.phase(), failure.message());
showError("Neue Datenbank konnte nicht angelegt werden: " + failure.message());
}
}
} finally {
dbBusyForDatabaseCreation.set(false);
applyDbBusyLock();
}
}
/**
* Wendet die aktuelle DB-Busy-Sperre auf die betroffenen UI-Komponenten an.
* <p>
* Während der Sperre sind die DB-lesenden und DB-schreibenden Aktionen des
* Verlauf-Tabs deaktiviert. Andere DB-Operationen laufen pro Aufruf frisch in
* Bootstrap und greifen automatisch den DB-Override des
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort} ab.
*/
private void applyDbBusyLock() {
if (historyTab != null) {
historyTab.setDbBusy(dbBusyForDatabaseCreation.get());
}
}
/** /**
* Checks on a background worker thread whether the target path already exists and, if so, * Checks on a background worker thread whether the target path already exists and, if so,
* asks the user to confirm overwriting on the FX Application Thread before writing. * asks the user to confirm overwriting on the FX Application Thread before writing.
@@ -1183,7 +1560,7 @@ public final class GuiConfigurationEditorWorkspace {
FileChooser fileChooser = saveFileChooserFactory.get(); FileChooser fileChooser = saveFileChooserFactory.get();
fileChooser.setTitle("Konfiguration speichern"); fileChooser.setTitle("Konfiguration speichern");
fileChooser.getExtensionFilters().add( fileChooser.getExtensionFilters().add(
new FileChooser.ExtensionFilter("Properties-Dateien", "*.properties")); new FileChooser.ExtensionFilter(PROPERTIES_FILTER_DESC, PROPERTIES_FILTER_EXT));
java.io.File proposedDirFile = DEFAULT_SAVE_PATH.getParent().toAbsolutePath().toFile(); java.io.File proposedDirFile = DEFAULT_SAVE_PATH.getParent().toAbsolutePath().toFile();
if (proposedDirFile.exists()) { if (proposedDirFile.exists()) {
fileChooser.setInitialDirectory(proposedDirFile); fileChooser.setInitialDirectory(proposedDirFile);
@@ -1283,13 +1660,13 @@ public final class GuiConfigurationEditorWorkspace {
@Override @Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() { public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure( return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
"NO_PATH", "Kein Prompt-Pfad konfiguriert."); "NO_PATH", NO_PROMPT_PATH_MSG);
} }
@Override @Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) { public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed( return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
"Kein Prompt-Pfad konfiguriert.", null); NO_PROMPT_PATH_MSG, null);
} }
@Override @Override
@@ -1298,7 +1675,7 @@ public final class GuiConfigurationEditorWorkspace {
de.gecheckt.pdf.umbenenner.application.validation.technicaltest de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) { .CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Prompt-Pfad konfiguriert."); .CorrectionOutcome.NotAttempted(suggestion, NO_PROMPT_PATH_MSG);
} }
}; };
} }
@@ -1369,32 +1746,33 @@ public final class GuiConfigurationEditorWorkspace {
scrollPane.setPadding(new Insets(0)); scrollPane.setPadding(new Insets(0));
editorTab.setContent(scrollPane); editorTab.setContent(scrollPane);
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), historyTab.tab(), promptEditorTab.tab()); tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), schedulerTab.tab(), historyTab.tab(), promptEditorTab.tab());
root.setCenter(tabPane); root.setCenter(tabPane);
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob // Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
// der Dateiname-Editor ungespeicherte Änderungen hat. // der Dateiname-Editor ungespeicherte Änderungen hat.
// Gleiches gilt für den Prompt-Tab. // Gleiches gilt für den Prompt-Tab.
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> { tabPane.getSelectionModel().selectedItemProperty().addListener(
if (oldTab == null || newTab == null) { (obs, oldTab, newTab) -> handleTabSwitch(oldTab, newTab));
return; }
private void handleTabSwitch(javafx.scene.control.Tab oldTab, javafx.scene.control.Tab newTab) {
if (oldTab == null || newTab == null) {
return;
}
if (oldTab == batchRunTab.tab() && batchRunTab.hasUnsavedFilenameEdits()) {
boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits();
if (!shouldDiscard) {
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
} }
if (oldTab == batchRunTab.tab() && batchRunTab.hasUnsavedFilenameEdits()) { } else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) {
// Selektion kurz unterdrücken um Rekursion zu vermeiden boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty();
boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits(); if (!shouldDiscard) {
if (!shouldDiscard) { Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
// Zurück zum Verarbeitungslauf-Tab } else {
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab)); promptEditorTab.discardChanges();
}
} else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) {
boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty();
if (!shouldDiscard) {
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
} else {
promptEditorTab.discardChanges();
}
} }
}); }
} }
private void configureActionBar() { private void configureActionBar() {
@@ -1429,6 +1807,11 @@ public final class GuiConfigurationEditorWorkspace {
dirtyMarkerLabel.setVisible(dirty); dirtyMarkerLabel.setVisible(dirty);
dirtyMarkerLabel.setManaged(dirty); dirtyMarkerLabel.setManaged(dirty);
// Tab-Titel mit Dirty-Indikator aktualisieren (UX-Konsistenz zum Prompt-Tab)
if (configurationTab != null) {
configurationTab.setText(dirty ? "* Konfiguration" : "Konfiguration");
}
titleUpdateListener.accept(GuiWindowTitleFormatter.format(editorState)); titleUpdateListener.accept(GuiWindowTitleFormatter.format(editorState));
} }
@@ -1561,10 +1944,12 @@ public final class GuiConfigurationEditorWorkspace {
TextField lockField = boundTextField( TextField lockField = boundTextField(
editorState.values().runtimeLockFile(), editorState.values().runtimeLockFile(),
val -> updateValues(editorState.values().withRuntimeLockFile(val))); val -> updateValues(editorState.values().withRuntimeLockFile(val)));
applyTooltip(lockField, GuiTooltipTexts.PFADE_LOCK_DATEI);
TextField logDirField = boundTextField( TextField logDirField = boundTextField(
editorState.values().logDirectory(), editorState.values().logDirectory(),
val -> updateValues(editorState.values().withLogDirectory(val))); val -> updateValues(editorState.values().withLogDirectory(val)));
applyTooltip(logDirField, GuiTooltipTexts.PFADE_LOG_VERZEICHNIS);
VBox optionalContent = new VBox(4); VBox optionalContent = new VBox(4);
optionalContent.setPadding(new Insets(6, 0, 0, 0)); optionalContent.setPadding(new Insets(6, 0, 0, 0));
@@ -1624,6 +2009,7 @@ public final class GuiConfigurationEditorWorkspace {
Button reloadModelsButton = new Button("Modelle neu laden"); Button reloadModelsButton = new Button("Modelle neu laden");
reloadModelsButton.setId("modelle-neu-laden-button"); reloadModelsButton.setId("modelle-neu-laden-button");
reloadModelsButton.setOnAction(event -> triggerModelRetrievalForCurrentProvider(providerComboBox)); reloadModelsButton.setOnAction(event -> triggerModelRetrievalForCurrentProvider(providerComboBox));
applyTooltip(reloadModelsButton, GuiTooltipTexts.PROVIDER_MODELLE_NEU_LADEN);
HBox comboRow = new HBox(8, providerComboBox, reloadModelsButton); HBox comboRow = new HBox(8, providerComboBox, reloadModelsButton);
comboRow.setAlignment(Pos.CENTER_LEFT); comboRow.setAlignment(Pos.CENTER_LEFT);
@@ -1845,6 +2231,7 @@ public final class GuiConfigurationEditorWorkspace {
val, pState2.model(), pState2.timeoutSeconds(), pState2.apiKey()))); val, pState2.model(), pState2.timeoutSeconds(), pState2.apiKey())));
Label baseUrlError = createFieldErrorLabel(); Label baseUrlError = createFieldErrorLabel();
fieldErrorLabels.put(ns + "baseUrl", baseUrlError); fieldErrorLabels.put(ns + "baseUrl", baseUrlError);
applyTooltip(baseUrlField, GuiTooltipTexts.PROVIDER_BASIS_URL);
HBox baseUrlBox = new HBox(4, baseUrlField); HBox baseUrlBox = new HBox(4, baseUrlField);
HBox.setHgrow(baseUrlField, Priority.ALWAYS); HBox.setHgrow(baseUrlField, Priority.ALWAYS);
fieldGrid.add(new Label("Basis-URL:"), 0, gridRow); fieldGrid.add(new Label("Basis-URL:"), 0, gridRow);
@@ -1853,6 +2240,7 @@ public final class GuiConfigurationEditorWorkspace {
TextField timeoutField = boundTextField(pState.timeoutSeconds(), TextField timeoutField = boundTextField(pState.timeoutSeconds(),
val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState( val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
pState2.baseUrl(), pState2.model(), val, pState2.apiKey()))); pState2.baseUrl(), pState2.model(), val, pState2.apiKey())));
applyTooltip(timeoutField, GuiTooltipTexts.PROVIDER_TIMEOUT);
Label timeoutError = createFieldErrorLabel(); Label timeoutError = createFieldErrorLabel();
fieldErrorLabels.put(ns + "timeoutSeconds", timeoutError); fieldErrorLabels.put(ns + "timeoutSeconds", timeoutError);
fieldGrid.add(new Label("Timeout (Sek.):"), 2, gridRow); fieldGrid.add(new Label("Timeout (Sek.):"), 2, gridRow);
@@ -1911,6 +2299,7 @@ public final class GuiConfigurationEditorWorkspace {
val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState( val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
pState2.baseUrl(), pState2.model(), pState2.timeoutSeconds(), pState2.baseUrl(), pState2.model(), pState2.timeoutSeconds(),
GuiProviderApiKeyState.unresolved(val)))); GuiProviderApiKeyState.unresolved(val))));
applyTooltip(apiKeyField, GuiTooltipTexts.PROVIDER_API_KEY);
Label apiKeyError = createFieldErrorLabel(); Label apiKeyError = createFieldErrorLabel();
fieldErrorLabels.put(ns + "apiKey", apiKeyError); fieldErrorLabels.put(ns + "apiKey", apiKeyError);
Label apiKeyOriginLabel = createApiKeyOriginLabel(); Label apiKeyOriginLabel = createApiKeyOriginLabel();
@@ -2002,6 +2391,7 @@ public final class GuiConfigurationEditorWorkspace {
TextField maxRetriesField = boundTextField( TextField maxRetriesField = boundTextField(
editorState.values().maxRetriesTransient(), editorState.values().maxRetriesTransient(),
val -> updateValues(editorState.values().withMaxRetriesTransient(val))); val -> updateValues(editorState.values().withMaxRetriesTransient(val)));
applyTooltip(maxRetriesField, GuiTooltipTexts.LIMITS_MAX_RETRIES);
grid.add(new Label("Max. Retries:"), 2, row); grid.add(new Label("Max. Retries:"), 2, row);
grid.add(maxRetriesField, 3, row); grid.add(maxRetriesField, 3, row);
row++; row++;
@@ -2010,6 +2400,7 @@ public final class GuiConfigurationEditorWorkspace {
TextField logLevelField = boundTextField( TextField logLevelField = boundTextField(
editorState.values().logLevel(), editorState.values().logLevel(),
val -> updateValues(editorState.values().withLogLevel(val))); val -> updateValues(editorState.values().withLogLevel(val)));
applyTooltip(logLevelField, GuiTooltipTexts.LIMITS_LOG_LEVEL);
grid.add(new Label("Log-Level:"), 0, row); grid.add(new Label("Log-Level:"), 0, row);
grid.add(logLevelField, 1, row); grid.add(logLevelField, 1, row);
@@ -2019,6 +2410,7 @@ public final class GuiConfigurationEditorWorkspace {
sensitiveCheck.setSelected(sensitive); sensitiveCheck.setSelected(sensitive);
sensitiveCheck.selectedProperty().addListener((obs, oldVal, newVal) -> sensitiveCheck.selectedProperty().addListener((obs, oldVal, newVal) ->
updateValues(editorState.values().withLogAiSensitive(Boolean.toString(newVal)))); updateValues(editorState.values().withLogAiSensitive(Boolean.toString(newVal))));
applyTooltip(sensitiveCheck, GuiTooltipTexts.LIMITS_SENSIBLE_KI_AUSGABE);
grid.add(new Label(), 2, row); grid.add(new Label(), 2, row);
grid.add(sensitiveCheck, 3, row); grid.add(sensitiveCheck, 3, row);
@@ -2149,6 +2541,7 @@ public final class GuiConfigurationEditorWorkspace {
card.getChildren().add(messagesListView); card.getChildren().add(messagesListView);
clearMessagesButton.setOnAction(e -> clearMessages()); clearMessagesButton.setOnAction(e -> clearMessages());
applyTooltip(clearMessagesButton, GuiTooltipTexts.TOOLBAR_MELDUNGEN_LEEREN);
HBox clearButtonRow = new HBox(clearMessagesButton); HBox clearButtonRow = new HBox(clearMessagesButton);
clearButtonRow.setAlignment(Pos.CENTER_LEFT); clearButtonRow.setAlignment(Pos.CENTER_LEFT);
card.getChildren().add(clearButtonRow); card.getChildren().add(clearButtonRow);
@@ -2274,7 +2667,7 @@ public final class GuiConfigurationEditorWorkspace {
for (EditorValidationFinding finding : report.findings()) { for (EditorValidationFinding finding : report.findings()) {
GuiMessageSeverity severity = toGuiSeverity(finding.severity()); GuiMessageSeverity severity = toGuiSeverity(finding.severity());
messages.add(GuiMessageEntry.of(severity, finding.message(), "Validierung")); messages.add(GuiMessageEntry.of(severity, finding.message(), OPERATION_VALIDATE));
if (finding.hasFieldKey()) { if (finding.hasFieldKey()) {
fieldFindings.add(new GuiFieldFinding(finding.fieldKey().orElseThrow(), fieldFindings.add(new GuiFieldFinding(finding.fieldKey().orElseThrow(),
severity, finding.message())); severity, finding.message()));
@@ -2283,7 +2676,7 @@ public final class GuiConfigurationEditorWorkspace {
// Replace validation-related entries; preserve model-catalog messages (from coordinator) // Replace validation-related entries; preserve model-catalog messages (from coordinator)
pendingMessages.removeIf(m -> m.source().isPresent() pendingMessages.removeIf(m -> m.source().isPresent()
&& "Validierung".equals(m.source().get())); && OPERATION_VALIDATE.equals(m.source().get()));
pendingMessages.addAll(messages); pendingMessages.addAll(messages);
pendingFieldFindings.clear(); pendingFieldFindings.clear();
@@ -2339,7 +2732,7 @@ public final class GuiConfigurationEditorWorkspace {
// Drop silent auto-validation entries so the central message area is not flooded // Drop silent auto-validation entries so the central message area is not flooded
// by keystroke-level background checks; explicit action entries always accumulate. // by keystroke-level background checks; explicit action entries always accumulate.
pendingMessages.removeIf(m -> m.source().isPresent() pendingMessages.removeIf(m -> m.source().isPresent()
&& "Validierung".equals(m.source().get())); && OPERATION_VALIDATE.equals(m.source().get()));
// Append a timestamped confirmation plus each concrete finding as its own entry. // Append a timestamped confirmation plus each concrete finding as its own entry.
int findingCount = report.findings().size(); int findingCount = report.findings().size();
@@ -2794,6 +3187,7 @@ public final class GuiConfigurationEditorWorkspace {
Button pickButton1 = new Button(""); Button pickButton1 = new Button("");
pickButton1.setOnAction(e -> onPick1.run()); pickButton1.setOnAction(e -> onPick1.run());
pickButton1.setMinWidth(32); pickButton1.setMinWidth(32);
applyTooltip(pickButton1, GuiTooltipTexts.PFADE_BROWSER_BUTTON);
HBox fieldBox1 = new HBox(4, field1, pickButton1); HBox fieldBox1 = new HBox(4, field1, pickButton1);
HBox.setHgrow(field1, Priority.ALWAYS); HBox.setHgrow(field1, Priority.ALWAYS);
fieldBox1.setAlignment(Pos.CENTER_LEFT); fieldBox1.setAlignment(Pos.CENTER_LEFT);
@@ -2821,6 +3215,7 @@ public final class GuiConfigurationEditorWorkspace {
Button pickButton2 = new Button(""); Button pickButton2 = new Button("");
pickButton2.setOnAction(e -> onPick2.run()); pickButton2.setOnAction(e -> onPick2.run());
pickButton2.setMinWidth(32); pickButton2.setMinWidth(32);
applyTooltip(pickButton2, GuiTooltipTexts.PFADE_BROWSER_BUTTON);
HBox fieldBox2 = new HBox(4, field2, pickButton2); HBox fieldBox2 = new HBox(4, field2, pickButton2);
HBox.setHgrow(field2, Priority.ALWAYS); HBox.setHgrow(field2, Priority.ALWAYS);
fieldBox2.setAlignment(Pos.CENTER_LEFT); fieldBox2.setAlignment(Pos.CENTER_LEFT);
@@ -2976,6 +3371,20 @@ public final class GuiConfigurationEditorWorkspace {
: exception.getMessage(); : exception.getMessage();
} }
/**
* Ermittelt die Dateiendung der aktiven SQLite-Konfigurationsdatei.
* Gibt {@code ".db"} zurück, wenn der Pfad auf {@code .db} endet, sonst {@code ".sqlite"}.
*
* @param sqliteFile konfigurierter SQLite-Pfad, darf {@code null} oder leer sein
* @return {@code ".db"} oder {@code ".sqlite"}
*/
private static String resolveDbExtension(String sqliteFile) {
if (sqliteFile != null && sqliteFile.toLowerCase().endsWith(".db")) {
return ".db";
}
return ".sqlite";
}
/** /**
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf das angegebene Control. * Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf das angegebene Control.
* <p> * <p>
@@ -3001,7 +3410,7 @@ public final class GuiConfigurationEditorWorkspace {
*/ */
private void saveLastConfigurationPath(Path configFilePath) { private void saveLastConfigurationPath(Path configFilePath) {
try { try {
Preferences prefs = Preferences.userNodeForPackage(GuiConfigurationEditorWorkspace.class); Preferences prefs = Preferences.userRoot().node("de/gecheckt/pdf-umbenenner");
prefs.put("lastConfigPath", configFilePath.toAbsolutePath().toString()); prefs.put("lastConfigPath", configFilePath.toAbsolutePath().toString());
LOG.debug("GUI-Editor: Letzter Konfigurationspfad gespeichert: {}", configFilePath); LOG.debug("GUI-Editor: Letzter Konfigurationspfad gespeichert: {}", configFilePath);
} catch (Exception exception) { } catch (Exception exception) {
@@ -3016,7 +3425,7 @@ public final class GuiConfigurationEditorWorkspace {
*/ */
public void autoLoadLastConfiguration() { public void autoLoadLastConfiguration() {
try { try {
Preferences prefs = Preferences.userNodeForPackage(GuiConfigurationEditorWorkspace.class); Preferences prefs = Preferences.userRoot().node("de/gecheckt/pdf-umbenenner");
String lastPath = prefs.get("lastConfigPath", null); String lastPath = prefs.get("lastConfigPath", null);
if (lastPath == null) { if (lastPath == null) {
@@ -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}. * {@code Platform.runLater}.
*/ */
public final class GuiModelCatalogCoordinator { public final class GuiModelCatalogCoordinator {
private static final String LOG_MODEL_FETCH_FMT = "GUI-Modellabruf: {} (Provider: {})";
private static final String OPERATION_MODELLABRUF = "Modellabruf";
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class); private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
@@ -203,7 +207,7 @@ public final class GuiModelCatalogCoordinator {
String previousManualValue) { String previousManualValue) {
// Remove any previous message entries from an earlier retrieval so messages do not // Remove any previous message entries from an earlier retrieval so messages do not
// accumulate across repeated triggers of the same retrieval action. // accumulate across repeated triggers of the same retrieval action.
pendingMessages.removeIf(msg -> "Modellabruf".equals(msg.source().orElse(""))); pendingMessages.removeIf(msg -> OPERATION_MODELLABRUF.equals(msg.source().orElse("")));
String displayName = displayNameFor(family); String displayName = displayNameFor(family);
@@ -213,28 +217,28 @@ public final class GuiModelCatalogCoordinator {
container.applyModelList(models, previousManualValue); container.applyModelList(models, previousManualValue);
String message = "Modellliste für " + displayName + " geladen (" String message = "Modellliste für " + displayName + " geladen ("
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ")."; + models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, "Modellabruf")); pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, OPERATION_MODELLABRUF));
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier()); LOG.info(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
} }
case ModelCatalogResult.EmptyList emptyList -> { case ModelCatalogResult.EmptyList emptyList -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT); container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Provider " + displayName String message = "Provider " + displayName
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv."; + " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf")); pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, OPERATION_MODELLABRUF));
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier()); LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
} }
case ModelCatalogResult.IncompleteConfiguration incomplete -> { case ModelCatalogResult.IncompleteConfiguration incomplete -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT); container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason() String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
+ ". Manuelle Eingabe aktiv."; + ". Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, "Modellabruf")); pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, OPERATION_MODELLABRUF));
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier()); LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
} }
case ModelCatalogResult.TechnicalFailure failure -> { case ModelCatalogResult.TechnicalFailure failure -> {
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT); container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar (" + failure.errorCategory() String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
+ "). Manuelle Eingabe aktiv."; + "). Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, "Modellabruf")); pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, OPERATION_MODELLABRUF));
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})", LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
message, failure.errorDetail(), family.getIdentifier()); message, failure.errorDetail(), family.getIdentifier());
} }
@@ -225,6 +225,7 @@ public class GuiPromptEditorTab {
textArea.setWrapText(true); textArea.setWrapText(true);
textArea.setFont(Font.font("Monospace", 13)); textArea.setFont(Font.font("Monospace", 13));
textArea.setPrefRowCount(20); textArea.setPrefRowCount(20);
textArea.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_TEXTAREA));
VBox.setVgrow(textArea, Priority.ALWAYS); VBox.setVgrow(textArea, Priority.ALWAYS);
// Dirty-State-Tracking // Dirty-State-Tracking
@@ -243,14 +244,13 @@ public class GuiPromptEditorTab {
statusLabel.setStyle("-fx-text-fill: #555555;"); statusLabel.setStyle("-fx-text-fill: #555555;");
// Buttons verdrahten // Buttons verdrahten
saveButton.setTooltip(new Tooltip("Prompt-Datei speichern (atomar, UTF-8).")); saveButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_SPEICHERN));
saveButton.setOnAction(e -> requestSave()); saveButton.setOnAction(e -> requestSave());
resetButton.setTooltip(new Tooltip("Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern.")); resetButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_ZURUECKSETZEN));
resetButton.setOnAction(e -> resetToDefault()); resetButton.setOnAction(e -> resetToDefault());
createDefaultButton.setTooltip(new Tooltip( createDefaultButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_STANDARD_ANLEGEN));
"Standard-Prompt-Datei am konfigurierten Pfad anlegen."));
createDefaultButton.setOnAction(e -> requestCreateDefault()); createDefaultButton.setOnAction(e -> requestCreateDefault());
createDefaultButton.setVisible(false); createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false); createDefaultButton.setManaged(false);
@@ -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();
}
}
@@ -18,6 +18,8 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocument
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult; import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort; import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
@@ -48,8 +50,29 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target * the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
* folder for documents that have not yet been successfully processed, and * folder for documents that have not yet been successfully processed, and
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing * the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
* context for documents that were skipped in the current run, and the resolved application * context for documents that were skipped in the current run, the resolved application
* version string that the status bar displays at the bottom of the main window. * version string that the status bar displays at the bottom of the main window, and the
* optional {@link SchedulerControlUseCase} for controlling the automatic scheduler.
* <p>
* The optional {@code applicationContextError} carries a human-readable German error
* message when the bootstrap-side application run context could not be initialised at
* startup (e.g., invalid or incomplete configuration). An empty value signals that the
* run context was built successfully and batch runs can be launched immediately.
* <p>
* The optional {@code schedulerControlUseCase} is present when the automatic scheduler
* was successfully wired at startup. An empty value means scheduler control is not
* available in this startup context (e.g., no valid configuration was loaded at startup).
* <p>
* The optional {@code configurationFileLockPort} is present when the GUI can acquire an
* OS-level exclusive lock on the configuration file before a manual batch run. When present,
* it is acquired by the {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}
* on the worker thread before each run and released in a finally block. An empty value means
* no locking is performed (e.g., no valid configuration was loaded at startup, or locking is
* not required in this context).
* <p>
* The {@code applicationContextInitializer} is invoked on a background thread each time the
* workspace loads a configuration file (auto-load at startup and manual open). Bootstrap
* provides an implementation that builds the application run context and wires the scheduler.
* <p> * <p>
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to * All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
* know about provider-specific HTTP details or adapter wiring. * know about provider-specific HTTP details or adapter wiring.
@@ -77,7 +100,15 @@ public record GuiStartupContext(
GuiHistoryDetailsPort historyDetailsPort, GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort, GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort, GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory) { GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort,
Optional<String> applicationContextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase,
Optional<ConfigurationFileLockPort> configurationFileLockPort,
GuiApplicationContextInitializer applicationContextInitializer) {
private static final String NO_PROMPT_PORT_MSG = "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.";
private static final String NO_PORT_MSG = "Kein Port in diesem Startkontext.";
/** /**
* Creates a fully wired startup context. * Creates a fully wired startup context.
@@ -110,10 +141,13 @@ public record GuiStartupContext(
* {@code null} sein * {@code null} sein
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel; * @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
* darf nicht {@code null} sein * darf nicht {@code null} sein
* @param applicationContextError optional error message when the application run context
* could not be initialised at startup; {@code null} becomes empty
*/ */
public GuiStartupContext { public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null"); initialState = Objects.requireNonNull(initialState, "initialState must not be null");
startupNotice = startupNotice == null ? Optional.empty() : startupNotice; startupNotice = Objects.requireNonNullElse(startupNotice, Optional.empty());
applicationContextError = Objects.requireNonNullElse(applicationContextError, Optional.empty());
configurationFileLoader = Objects.requireNonNull(configurationFileLoader, configurationFileLoader = Objects.requireNonNull(configurationFileLoader,
"configurationFileLoader must not be null"); "configurationFileLoader must not be null");
configurationFileWriter = Objects.requireNonNull(configurationFileWriter, configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
@@ -155,6 +189,153 @@ public record GuiStartupContext(
"deleteDocumentHistoryPort must not be null"); "deleteDocumentHistoryPort must not be null");
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory, promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
"promptEditorPortFactory must not be null"); "promptEditorPortFactory must not be null");
createNewDatabasePort = Objects.requireNonNull(createNewDatabasePort,
"createNewDatabasePort must not be null");
schedulerControlUseCase = Objects.requireNonNullElse(schedulerControlUseCase, Optional.empty());
configurationFileLockPort = Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
applicationContextInitializer = applicationContextInitializer == null
? GuiApplicationContextInitializer.noOp() : applicationContextInitializer;
}
/**
* Backward-compatible constructor that fills {@code schedulerControlUseCase} with
* {@link Optional#empty()}.
* <p>
* Preserves existing callers that were written before the scheduler was added.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets document status; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file; must not be {@code null}
* @param manualFileCopyPort bridge that copies a source file; must not be {@code null}
* @param historicalDocumentContextPort bridge for historical processing context; must not be {@code null}
* @param applicationVersion resolved application version string; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; must not be {@code null}
* @param historyOverviewPort bridge for history overview; must not be {@code null}
* @param historyDetailsPort bridge for history details; must not be {@code null}
* @param historyResetDocumentStatusPort bridge for history reset; must not be {@code null}
* @param deleteDocumentHistoryPort bridge for history deletion; must not be {@code null}
* @param promptEditorPortFactory factory for prompt editor ports; must not be {@code null}
* @param createNewDatabasePort bridge for new database creation; must not be {@code null}
* @param applicationContextError optional error from context init; {@code null} becomes empty
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort,
Optional<String> applicationContextError) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, manualFileRenamePort, manualFileCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, Optional.empty(), Optional.empty(),
GuiApplicationContextInitializer.noOp());
}
/**
* Backward-compatible constructor that fills {@code configurationFileLockPort} with
* {@link Optional#empty()}.
* <p>
* Preserves existing callers that were written before the configuration file lock port
* was added.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets document status; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file; must not be {@code null}
* @param manualFileCopyPort bridge that copies a source file; must not be {@code null}
* @param historicalDocumentContextPort bridge for historical processing context; must not be {@code null}
* @param applicationVersion resolved application version string; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; must not be {@code null}
* @param historyOverviewPort bridge for history overview; must not be {@code null}
* @param historyDetailsPort bridge for history details; must not be {@code null}
* @param historyResetDocumentStatusPort bridge for history reset; must not be {@code null}
* @param deleteDocumentHistoryPort bridge for history deletion; must not be {@code null}
* @param promptEditorPortFactory factory for prompt editor ports; must not be {@code null}
* @param createNewDatabasePort bridge for new database creation; must not be {@code null}
* @param applicationContextError optional error from context init; {@code null} becomes empty
* @param schedulerControlUseCase optional scheduler control use case; {@code null} becomes empty
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort,
Optional<String> applicationContextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, manualFileRenamePort, manualFileCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, schedulerControlUseCase, Optional.empty(),
GuiApplicationContextInitializer.noOp());
} }
/** /**
@@ -198,7 +379,8 @@ public record GuiStartupContext(
rejectingManualFileCopyPort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(), noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(), noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory()); noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(), Optional.empty());
} }
/** /**
@@ -236,7 +418,8 @@ public record GuiStartupContext(
rejectingManualFileCopyPort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(), noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(), noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory()); noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(), Optional.empty());
} }
/** /**
@@ -274,7 +457,8 @@ public record GuiStartupContext(
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(), noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(), noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory()); noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(), Optional.empty());
} }
private static GuiBatchRunLauncher rejectingBatchRunLauncher() { private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -352,21 +536,21 @@ public record GuiStartupContext(
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreateDirectory suggestion) { .CorrectionSuggestion.CreateDirectory suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext."); .CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
} }
@Override @Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) { .CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext."); .CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
} }
@Override @Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.PrepareSqlitePath suggestion) { .CorrectionSuggestion.PrepareSqlitePath suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext."); .CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
} }
}; };
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort); CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
@@ -396,7 +580,26 @@ public record GuiStartupContext(
noOpHistoryDetailsPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpHistoryResetPort(),
noOpDeleteHistoryPort(), noOpDeleteHistoryPort(),
noOpPromptEditorPortFactory()); 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() { private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
@@ -408,13 +611,13 @@ public record GuiStartupContext(
@Override @Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() { public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure( return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
"NO_OP", "Kein Prompt-Editor-Port in diesem Startkontext verfügbar."); "NO_OP", NO_PROMPT_PORT_MSG);
} }
@Override @Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) { public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed( return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
"Kein Prompt-Editor-Port in diesem Startkontext verfügbar.", null); NO_PROMPT_PORT_MSG, null);
} }
@Override @Override
@@ -424,7 +627,7 @@ public record GuiStartupContext(
.CorrectionSuggestion.CreatePromptFile suggestion) { .CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted( .CorrectionOutcome.NotAttempted(
suggestion, "Kein Prompt-Editor-Port in diesem Startkontext verfügbar."); suggestion, NO_PROMPT_PORT_MSG);
} }
}; };
} }
@@ -29,6 +29,9 @@ import javafx.scene.layout.Region;
* Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung. * Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung.
*/ */
public final class GuiStatusBar { public final class GuiStatusBar {
private static final String LABEL_STYLE = "-fx-font-size: 11px; -fx-text-fill: #555555;";
/** Anzeigetext wenn keine Konfiguration geladen ist. */ /** Anzeigetext wenn keine Konfiguration geladen ist. */
static final String KEIN_PROFIL_TEXT = "Kein Profil geladen"; static final String KEIN_PROFIL_TEXT = "Kein Profil geladen";
@@ -58,16 +61,16 @@ public final class GuiStatusBar {
// Linkes Segment: Versionsanzeige // Linkes Segment: Versionsanzeige
this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion); this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion);
this.versionLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;"); this.versionLabel.setStyle(LABEL_STYLE);
// Mittleres Segment: Provider und Modell // Mittleres Segment: Provider und Modell
this.providerLabel = new Label(KEIN_PROFIL_TEXT); this.providerLabel = new Label(KEIN_PROFIL_TEXT);
this.providerLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;"); this.providerLabel.setStyle(LABEL_STYLE);
this.providerLabel.setAlignment(Pos.CENTER); this.providerLabel.setAlignment(Pos.CENTER);
// Rechtes Segment: Konfigurationspfad // Rechtes Segment: Konfigurationspfad
this.configPathLabel = new Label(KEIN_PROFIL_TEXT); this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
this.configPathLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;"); this.configPathLabel.setStyle(LABEL_STYLE);
this.configPathLabel.setAlignment(Pos.CENTER_RIGHT); this.configPathLabel.setAlignment(Pos.CENTER_RIGHT);
// Abstandhalter zwischen den Segmenten // Abstandhalter zwischen den Segmenten
@@ -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();
}
}
@@ -65,6 +65,14 @@ public final class GuiTooltipTexts {
public static final String PFADE_PROMPT = public static final String PFADE_PROMPT =
"Externe Textdatei mit den KI-Anweisungen."; "Externe Textdatei mit den KI-Anweisungen.";
/** Tooltip für das Eingabefeld „Lock-Datei". */
public static final String PFADE_LOCK_DATEI =
"Pfad zur Lock-Datei, die parallele Instanzen verhindert (optional).";
/** Tooltip für das Eingabefeld „Log-Verzeichnis". */
public static final String PFADE_LOG_VERZEICHNIS =
"Verzeichnis für Log-Dateien. Leer = Standardverzeichnis logs/ im Programmverzeichnis.";
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Konfigurationstab Provider // Konfigurationstab Provider
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -77,6 +85,18 @@ public final class GuiTooltipTexts {
public static final String PROVIDER_MODELL = public static final String PROVIDER_MODELL =
"Das konkrete Sprachmodell des gewählten Providers."; "Das konkrete Sprachmodell des gewählten Providers.";
/** Tooltip für das Eingabefeld „Basis-URL". */
public static final String PROVIDER_BASIS_URL =
"Basis-URL des KI-Dienstes (z. B. https://api.openai.com/v1).";
/** Tooltip für das Eingabefeld „Timeout". */
public static final String PROVIDER_TIMEOUT =
"Zeitlimit für KI-Anfragen in Sekunden.";
/** Tooltip für das Eingabefeld „API-Key". */
public static final String PROVIDER_API_KEY =
"API-Schlüssel für den konfigurierten KI-Dienst. Umgebungsvariable hat Vorrang.";
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Konfigurationstab Verarbeitungslimits // Konfigurationstab Verarbeitungslimits
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -93,10 +113,26 @@ public final class GuiTooltipTexts {
public static final String LIMITS_MAX_TITLE_LENGTH = public static final String LIMITS_MAX_TITLE_LENGTH =
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10120."; "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 // Verarbeitungslauf-Tab Dateiname-Editor
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** Tooltip für das Dateiname-Textfeld im Dateiname-Editor. */
public static final String DATEINAME_TEXTFELD =
"Dateiname bearbeiten. Format: JJJJ-MM-TT - Titel.pdf";
/** Tooltip für den Button „Dateiname übernehmen". */ /** Tooltip für den Button „Dateiname übernehmen". */
public static final String DATEINAME_UEBERNEHMEN = public static final String DATEINAME_UEBERNEHMEN =
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen."; "Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.";
@@ -105,6 +141,154 @@ public final class GuiTooltipTexts {
public static final String DATEINAME_ZURUECKSETZEN = public static final String DATEINAME_ZURUECKSETZEN =
"Stellt den KI-generierten Namen wieder her, ohne zu speichern."; "Stellt den KI-generierten Namen wieder her, ohne zu speichern.";
// -------------------------------------------------------------------------
// Verarbeitungslauf-Tab Laufsteuerung und Tabelle
// -------------------------------------------------------------------------
/** Tooltip für den Button „Starten". */
public static final String BATCHRUN_STARTEN =
"Verarbeitungslauf starten: alle ausstehenden PDF-Dateien aus dem Quellordner verarbeiten.";
/** Tooltip für den Button „Abbrechen". */
public static final String BATCHRUN_ABBRECHEN =
"Laufenden Verarbeitungslauf abbrechen. Bereits abgeschlossene Dateien bleiben gespeichert.";
/** Tooltip für den Button „Erneut verarbeiten". */
public static final String BATCHRUN_ERNEUT_VERARBEITEN =
"Markierte Einträge erneut zur Verarbeitung freigeben (setzt Status auf READY_FOR_AI).";
/** Tooltip für den Button „Status zurücksetzen" im Verarbeitungslauf-Tab. */
public static final String BATCHRUN_STATUS_ZURUECKSETZEN =
"Status der markierten Einträge zurücksetzen, damit sie beim nächsten Lauf verarbeitet werden.";
/** Tooltip für die Master-Checkbox im Tabellenkopf des Verarbeitungslauf-Tabs. */
public static final String BATCHRUN_MASTER_CHECKBOX =
"Alle sichtbaren Einträge markieren oder Markierung aufheben.";
/** Tooltip für den Meldungsbereich im Verarbeitungslauf-Tab. */
public static final String BATCHRUN_MESSAGE_AREA =
"Statusmeldungen und Fortschrittsinformationen des aktuellen Verarbeitungslaufs.";
/** Tooltip für den Navigations-Button „Vorherige Seite" in der PDF-Vorschau. */
public static final String PREVIEW_VORHERIGE_SEITE =
"Vorherige Seite der Vorschau anzeigen.";
/** Tooltip für den Navigations-Button „Nächste Seite" in der PDF-Vorschau. */
public static final String PREVIEW_NAECHSTE_SEITE =
"Nächste Seite der Vorschau anzeigen.";
/** Tooltip für Spalte „Status" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_STATUS =
"Verarbeitungsergebnis: Erfolg, Fehler oder übersprungen.";
/** Tooltip für Spalte „Originaldateiname" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_ORIGINALDATEINAME =
"Ursprünglicher Dateiname der verarbeiteten PDF-Datei.";
/** Tooltip für Spalte „Neuer Dateiname" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_NEUER_DATEINAME =
"Von der KI vorgeschlagener, normierter Dateiname.";
/** Tooltip für Spalte „Datum" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_DATUM =
"Datum des Dokuments laut KI-Analyse.";
/** Tooltip für Spalte „Dauer" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_DAUER =
"Verarbeitungsdauer für diese Datei.";
// -------------------------------------------------------------------------
// Verlauf-Tab Detailbereich
// -------------------------------------------------------------------------
/** Tooltip für den KI-Begründungs-Bereich im Verlauf-Tab. */
public static final String VERLAUF_REASONING_AREA =
"KI-Begründung des ausgewählten Verarbeitungsversuchs.";
/** Tooltip für den Fehlerursachen-Bereich im Verlauf-Tab. */
public static final String VERLAUF_FAILURE_AREA =
"Fehlermeldung des letzten Fehler-Versuchs für dieses Dokument.";
/** Tooltip für Spalte „Status" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_STATUS =
"Aktueller Gesamtstatus des Dokuments.";
/** Tooltip für Spalte „Quelldatei" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_QUELLDATEI =
"Ursprünglicher Dateiname der PDF-Quelldatei.";
/** Tooltip für Spalte „Zieldatei" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_ZIELDATEI =
"Vom System erzeugter, normierter Dateiname im Zielordner.";
/** Tooltip für Spalte „Letzter Versuch" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_LETZTER_VERSUCH =
"Zeitpunkt des zuletzt abgeschlossenen Verarbeitungsversuchs.";
/** Tooltip für Spalte „Versuche" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_VERSUCHE =
"Gesamtanzahl der Verarbeitungsversuche für dieses Dokument.";
/** Tooltip für Spalte „#" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_NR =
"Laufende Nummer des Verarbeitungsversuchs.";
/** Tooltip für Spalte „Datum" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_DATUM =
"Endzeitpunkt dieses Verarbeitungsversuchs.";
/** Tooltip für Spalte „Status" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_STATUS =
"Ergebnis dieses Verarbeitungsversuchs.";
/** Tooltip für Spalte „Provider" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_PROVIDER =
"KI-Provider, der für diesen Versuch verwendet wurde.";
/** Tooltip für Spalte „Modell" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_MODELL =
"Konkretes Sprachmodell, das für diesen Versuch verwendet wurde.";
/** Tooltip für Spalte „Vorgeschlagener Name" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_VORGESCHLAGENER_NAME =
"Vom System erzeugter Zieldateiname für diesen Versuch.";
// -------------------------------------------------------------------------
// Konfigurations-Tab Meldungsbereich und Modell-Neu-Laden
// -------------------------------------------------------------------------
/** Tooltip für den Button „Meldungen leeren". */
public static final String TOOLBAR_MELDUNGEN_LEEREN =
"Alle Meldungen im Meldungsbereich entfernen.";
/** Tooltip für den Button „Modelle neu laden". */
public static final String PROVIDER_MODELLE_NEU_LADEN =
"Verfügbare Modelle vom konfigurierten Provider neu abrufen.";
/** Tooltip für den Ordner-/Datei-Browser-Button. */
public static final String PFADE_BROWSER_BUTTON =
"Ordner oder Datei über den Datei-Dialog auswählen.";
// -------------------------------------------------------------------------
// Prompt-Tab Textbereich
// -------------------------------------------------------------------------
/** Tooltip für den Prompt-Textbereich im Prompt-Editor-Tab. */
public static final String PROMPT_TEXTAREA =
"KI-Anweisungstext. Dieser Prompt wird bei jedem Verarbeitungsversuch an das Sprachmodell gesendet.";
/** Tooltip für den Button „Speichern" im Prompt-Editor-Tab. */
public static final String PROMPT_SPEICHERN =
"Prompt-Datei speichern (atomar, UTF-8).";
/** Tooltip für den Button „Auf Standard zurücksetzen" im Prompt-Editor-Tab. */
public static final String PROMPT_ZURUECKSETZEN =
"Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern.";
/** Tooltip für den Button „Standard-Prompt erstellen" im Prompt-Editor-Tab. */
public static final String PROMPT_STANDARD_ANLEGEN =
"Standard-Prompt-Datei am konfigurierten Pfad anlegen.";
/** Nicht instanziierbar reine Konstantenklasse. */ /** Nicht instanziierbar reine Konstantenklasse. */
private GuiTooltipTexts() { private GuiTooltipTexts() {
throw new UnsupportedOperationException("Nicht instanziierbar"); throw new UnsupportedOperationException("Nicht instanziierbar");
@@ -7,6 +7,10 @@ import javafx.application.Application;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.event.EventHandler; import javafx.event.EventHandler;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuBar;
import javafx.scene.control.MenuItem;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import javafx.stage.Stage; import javafx.stage.Stage;
@@ -26,6 +30,10 @@ import javafx.stage.WindowEvent;
* *
* <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert. * <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert.
* Über das Tray-Kontextmenü kann das Fenster wieder geöffnet oder die Anwendung beendet werden. * Über das Tray-Kontextmenü kann das Fenster wieder geöffnet oder die Anwendung beendet werden.
*
* <p>Nach dem Anzeigen des Hauptfensters startet eine zentrale {@link GuiStatusRefreshTimeline}
* (1 Hz), die den aktuellen Scheduler-Status liest und alle betroffenen Tabs aktualisiert.
* Die Timeline wird beim Beenden der Anwendung gestoppt.
*/ */
public class PdfUmbenennerGuiApplication extends Application { public class PdfUmbenennerGuiApplication extends Application {
@@ -34,6 +42,9 @@ public class PdfUmbenennerGuiApplication extends Application {
private static final double DEFAULT_HEIGHT = 800; private static final double DEFAULT_HEIGHT = 800;
private SystemTrayManager trayManager; private SystemTrayManager trayManager;
private GuiConfigurationEditorWorkspace workspace;
private GuiStartupContext guiStartupContext;
private GuiStatusRefreshTimeline refreshTimeline;
/** /**
* Creates a new instance of the JavaFX application. * Creates a new instance of the JavaFX application.
@@ -49,6 +60,8 @@ public class PdfUmbenennerGuiApplication extends Application {
* Wires the workspace title-update listener to the stage title so any dirty-state change * Wires the workspace title-update listener to the stage title so any dirty-state change
* causes an immediate window-title refresh. Installs the close-request handler that * causes an immediate window-title refresh. Installs the close-request handler that
* guards unsaved changes and minimizes the window to the system tray instead of closing. * guards unsaved changes and minimizes the window to the system tray instead of closing.
* <p>
* Startet nach dem Anzeigen des Fensters die zentrale Status-Refresh-Timeline.
* *
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null} * @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
*/ */
@@ -64,18 +77,22 @@ public class PdfUmbenennerGuiApplication extends Application {
new Image(getClass().getResourceAsStream("/icons/Icon128.png")) new Image(getClass().getResourceAsStream("/icons/Icon128.png"))
); );
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank(); guiStartupContext = GuiStartupContextHolder.currentOrBlank();
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext); workspace = new GuiConfigurationEditorWorkspace(guiStartupContext);
// Wire the title-update listener so the stage title stays in sync with the dirty state. // Wire the title-update listener so the stage title stays in sync with the dirty state.
workspace.titleUpdateListener = primaryStage::setTitle; workspace.titleUpdateListener = primaryStage::setTitle;
// Statuszeile anlegen und mit dem Workspace verdrahten // Statuszeile anlegen und mit dem Workspace verdrahten
GuiStatusBar statusBar = new GuiStatusBar(startupContext.applicationVersion()); GuiStatusBar statusBar = new GuiStatusBar(guiStartupContext.applicationVersion());
workspace.statusBarStateListener = statusBar::applyEditorState; workspace.statusBarStateListener = statusBar::applyEditorState;
// Menüleiste mit Datenbank-Menü (Neue Datenbank anlegen")
MenuBar menuBar = buildMenuBar(workspace);
// Statuszeile unterhalb des Workspace-Inhalts einbetten // Statuszeile unterhalb des Workspace-Inhalts einbetten
BorderPane outerLayout = new BorderPane(); BorderPane outerLayout = new BorderPane();
outerLayout.setTop(menuBar);
outerLayout.setCenter(workspace.root()); outerLayout.setCenter(workspace.root());
outerLayout.setBottom(statusBar.root()); outerLayout.setBottom(statusBar.root());
@@ -93,28 +110,114 @@ public class PdfUmbenennerGuiApplication extends Application {
installTrayCloseHandler(primaryStage, workspace); installTrayCloseHandler(primaryStage, workspace);
} }
// Scheduler-Close-Guard als äußerste Schicht: verhindert Beenden während Scheduler aktiv
installSchedulerCloseGuard(primaryStage);
primaryStage.setMaximized(true); primaryStage.setMaximized(true);
primaryStage.show(); primaryStage.show();
// Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden. // Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden.
workspace.autoLoadLastConfiguration(); workspace.autoLoadLastConfiguration();
// Zentrale Status-Refresh-Timeline starten (1 Hz)
refreshTimeline = new GuiStatusRefreshTimeline(
guiStartupContext.schedulerControlUseCase(),
this::refreshAllTabStates);
refreshTimeline.start();
LOG.info("GUI: Hauptfenster erfolgreich angezeigt."); LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
} }
/** /**
* Called by the JavaFX runtime when the application is stopping. * Called by the JavaFX runtime when the application is stopping.
* <p> * <p>
* Entfernt das System-Tray-Icon und loggt das Beenden. * Stoppt die Status-Refresh-Timeline, entfernt das System-Tray-Icon und loggt das Beenden.
*/ */
@Override @Override
public void stop() { public void stop() {
LOG.info("GUI: JavaFX-Anwendung wird beendet."); LOG.info("GUI: JavaFX-Anwendung wird beendet.");
if (refreshTimeline != null) {
refreshTimeline.stop();
}
if (trayManager != null) { if (trayManager != null) {
trayManager.remove(); trayManager.remove();
} }
} }
/**
* Liest den aktuellen Scheduler-Status und aktualisiert alle betroffenen Tabs.
* <p>
* Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem JavaFX
* Application Thread aufgerufen. Wenn kein {@link SchedulerControlUseCase} vorhanden
* ist, wird der Aufruf ohne Fehler übersprungen.
*/
private void refreshAllTabStates() {
// Den Use Case nicht aus dem unveränderlichen GuiStartupContext lesen, sondern
// den zur Laufzeit (z. B. durch Auto-Load) verdrahteten Use Case verwenden.
// Andernfalls bliebe der Stop-Button dauerhaft deaktiviert, weil updateStatus
// nie aufgerufen würde.
workspace.refreshSchedulerStatus();
}
/**
* Baut die Menüleiste für das Hauptfenster auf.
* <p>
* Aktuell enthält sie genau einen Eintrag: das Menü Datenbank" mit der Aktion
* Neue Datenbank anlegen". Diese delegiert an
* {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}.
* <p>
* Der Menüpunkt ist deaktiviert, solange ein Verarbeitungslauf aktiv ist oder
* bereits eine DB-Anlage läuft. Die Reaktivierung erfolgt automatisch, sobald
* der Workspace die DB-Busy-Sperre wieder aufhebt.
*
* @param workspace der Workspace, an den die Aktionen delegieren; nie {@code null}
* @return die fertig konfigurierte Menüleiste
*/
private MenuBar buildMenuBar(GuiConfigurationEditorWorkspace workspace) {
Menu databaseMenu = new Menu("Datenbank");
MenuItem createNewItem = new MenuItem("Neue Datenbank anlegen…");
createNewItem.setOnAction(event -> workspace.requestCreateNewDatabase());
// Sperre während eines aktiven Verarbeitungslaufs oder einer laufenden DB-Anlage
createNewItem.disableProperty().bind(workspace.batchRunRunningProperty()
.or(workspace.dbBusyForDatabaseCreationProperty()));
databaseMenu.getItems().add(createNewItem);
return new MenuBar(databaseMenu);
}
/**
* Legt den Scheduler-Close-Guard als äußerste Schicht des Close-Request-Handlers an.
* <p>
* Ist kein {@link de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase}
* vorhanden, bleibt der bestehende Handler unverändert. Ist der Scheduler aktiv
* (Zustand != {@code STOPPED}), wird das Schließen verhindert und ein
* Informationsdialog angezeigt. Ist der Scheduler gestoppt, wird der bisherige
* Handler (SystemTray + Workspace-Dirty-Guard) aufgerufen.
*
* @param stage das primäre Fenster; darf nicht {@code null} sein
*/
private void installSchedulerCloseGuard(Stage stage) {
EventHandler<WindowEvent> existingHandler = stage.getOnCloseRequest();
stage.setOnCloseRequest(event -> {
// Use Case dynamisch über den Workspace lesen, weil der Scheduler erst
// nach erfolgreichem Datei-Öffnen (z. B. Auto-Load) verdrahtet wird und
// damit nicht zwingend im unveränderlichen GuiStartupContext steht.
if (workspace.isSchedulerActive()) {
event.consume();
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle("Anwendung kann nicht beendet werden");
alert.setHeaderText(null);
alert.setContentText(
"Ein Lauf ist aktiv oder der Scheduler läuft.\n"
+ "Bitte beende den Scheduler bzw. warte auf das Ende des Laufs.");
alert.showAndWait();
return;
}
if (existingHandler != null) {
existingHandler.handle(event);
}
});
}
/** /**
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den * Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
* System-Tray minimiert statt es zu schließen. * System-Tray minimiert statt es zu schließen.
@@ -2,7 +2,7 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
/** /**
* Übersetzt strukturierte Fehlermeldungen aus der Anwendungsschicht in * Übersetzt strukturierte Fehlermeldungen aus der Anwendungsschicht in
* benutzerfreundliche deutsche Texte für den Detailbereich des Verarbeitungslauf-Tabs. * benutzerfreundliche deutsche Texte für die Darstellungsschicht der GUI.
* <p> * <p>
* Die Klasse wertet die englischsprachige Fehlermeldung aus dem Verarbeitungsversuch * Die Klasse wertet die englischsprachige Fehlermeldung aus dem Verarbeitungsversuch
* musterbasiert aus und liefert eine für den Endbenutzer lesbare Beschreibung des * musterbasiert aus und liefert eine für den Endbenutzer lesbare Beschreibung des
@@ -12,8 +12,10 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
* Die Mustererkennung erfolgt ohne Berücksichtigung der Groß-/Kleinschreibung * Die Mustererkennung erfolgt ohne Berücksichtigung der Groß-/Kleinschreibung
* und prüft die definierten Schlüsselbegriffe in festgelegter Reihenfolge, * und prüft die definierten Schlüsselbegriffe in festgelegter Reihenfolge,
* damit spezifischere Muster vor allgemeineren greifen. * damit spezifischere Muster vor allgemeineren greifen.
* <p>
* Die Klasse wird sowohl im Verarbeitungslauf-Tab als auch im Verlauf-Tab verwendet.
*/ */
final class AiFailureMessageTranslator { public final class AiFailureMessageTranslator {
private AiFailureMessageTranslator() { private AiFailureMessageTranslator() {
} }
@@ -28,7 +30,7 @@ final class AiFailureMessageTranslator {
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein * @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
* @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol * @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol
*/ */
static String translate(String technicalMessage) { public static String translate(String technicalMessage) {
if (technicalMessage == null || technicalMessage.isBlank()) { if (technicalMessage == null || technicalMessage.isBlank()) {
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten."; return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
} }
@@ -7,7 +7,6 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus; import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.application.Platform;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
@@ -76,6 +76,9 @@ public final class FileNameEditorPane {
sectionTitle.setStyle("-fx-font-weight: bold;"); sectionTitle.setStyle("-fx-font-weight: bold;");
textField.setId("filename-editor-text-field"); textField.setId("filename-editor-text-field");
Tooltip textFieldTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_TEXTFELD);
textFieldTooltip.setShowDelay(Duration.millis(300));
textField.setTooltip(textFieldTooltip);
HBox.setHgrow(textField, Priority.ALWAYS); HBox.setHgrow(textField, Priority.ALWAYS);
HBox inputRow = new HBox(4, textField); HBox inputRow = new HBox(4, textField);
@@ -21,9 +21,12 @@ import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext; import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult; import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary; import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId; import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.scene.control.Alert;
/** /**
* Coordinates a single batch run (regular or targeted mini-run) triggered from the * Coordinates a single batch run (regular or targeted mini-run) triggered from the
@@ -60,6 +63,9 @@ import javafx.application.Platform;
* </ol> * </ol>
*/ */
public final class GuiBatchRunCoordinator { public final class GuiBatchRunCoordinator {
private static final String CONFIG_FILE_NOT_NULL = "configFilePath must not be null";
private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class); private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
private static final String WORKER_THREAD_NAME = "gui-batch-run"; private static final String WORKER_THREAD_NAME = "gui-batch-run";
@@ -115,6 +121,7 @@ public final class GuiBatchRunCoordinator {
private final Consumer<Runnable> fxDispatcher; private final Consumer<Runnable> fxDispatcher;
private final Listener listener; private final Listener listener;
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort; private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
private final Optional<ConfigurationFileLockPort> configurationFileLockPort;
private final AtomicReference<Thread> activeWorker = new AtomicReference<>(); private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
private final AtomicBoolean cancellationRequested = new AtomicBoolean(); private final AtomicBoolean cancellationRequested = new AtomicBoolean();
@@ -176,6 +183,33 @@ public final class GuiBatchRunCoordinator {
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort); defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort);
} }
/**
* Creates the coordinator with all ports and the configuration file lock port, using
* the default worker-thread factory and JavaFX Application Thread dispatcher.
* <p>
* This constructor is intended for production wiring in {@code GuiBatchRunTab} where
* the lock port is supplied by Bootstrap.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param listener GUI listener invoked on the FX thread; must not be null
* @param historicalDocumentContextPort port for resolving historical context; must not be null
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
* acquired before each run; {@code null} is treated as empty
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
this(launcher, miniRunLauncher, resetPort,
defaultThreadFactory(), defaultFxDispatcher(), listener,
historicalDocumentContextPort, configurationFileLockPort);
}
/** /**
* Creates the coordinator with custom hooks for the worker-thread factory and the * Creates the coordinator with custom hooks for the worker-thread factory and the
* UI-thread dispatcher. * UI-thread dispatcher.
@@ -205,22 +239,25 @@ public final class GuiBatchRunCoordinator {
} }
/** /**
* Creates the coordinator with all ports, custom thread factory, FX dispatcher and * Creates the coordinator with all ports, custom thread factory, FX dispatcher,
* historical file name port. * historical file name port, and an optional configuration file lock port.
* <p> * <p>
* This is the canonical constructor. All other constructors delegate here. * This is the canonical constructor. All other constructors delegate here.
* *
* @param launcher bridge to Bootstrap for regular batch runs; must not be null * @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 miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must * @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null * not be null
* @param threadFactory factory returning a ready-to-start worker thread; must not * @param threadFactory factory returning a ready-to-start worker thread; must not
* be null * be null
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application * @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
* Thread; must not be null * Thread; must not be null
* @param listener GUI listener; must not be null * @param listener GUI listener; must not be null
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for * @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
* skipped documents; must not be null * skipped documents; must not be null
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
* acquired before each run and released in a finally block;
* {@code null} is treated as empty
*/ */
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher, GuiMiniRunLauncher miniRunLauncher,
@@ -228,7 +265,8 @@ public final class GuiBatchRunCoordinator {
Function<Runnable, Thread> threadFactory, Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher, Consumer<Runnable> fxDispatcher,
Listener listener, Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort) { GuiHistoricalDocumentContextPort historicalDocumentContextPort,
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null"); this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher 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.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
@@ -237,6 +275,37 @@ public final class GuiBatchRunCoordinator {
this.listener = Objects.requireNonNull(listener, "listener must not be null"); this.listener = Objects.requireNonNull(listener, "listener must not be null");
this.historicalDocumentContextPort = Objects.requireNonNull( this.historicalDocumentContextPort = Objects.requireNonNull(
historicalDocumentContextPort, "historicalDocumentContextPort must not be null"); 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,
GuiResetDocumentStatusPort resetPort,
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
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} * @throws NullPointerException if {@code configFilePath} is {@code null}
*/ */
public boolean start(Path configFilePath) { public boolean start(Path configFilePath) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null"); Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
if (isRunning()) { if (isRunning()) {
return false; return false;
} }
@@ -313,7 +382,7 @@ public final class GuiBatchRunCoordinator {
*/ */
public boolean startMiniRun(Path configFilePath, public boolean startMiniRun(Path configFilePath,
Set<DocumentFingerprint> fingerprintFilter) { Set<DocumentFingerprint> fingerprintFilter) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null"); Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null"); Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
if (isRunning()) { if (isRunning()) {
return false; return false;
@@ -345,7 +414,7 @@ public final class GuiBatchRunCoordinator {
*/ */
public boolean startReprocessing(Path configFilePath, public boolean startReprocessing(Path configFilePath,
Set<DocumentFingerprint> fingerprintFilter) { Set<DocumentFingerprint> fingerprintFilter) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null"); Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null"); Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
if (isRunning()) { if (isRunning()) {
return false; return false;
@@ -386,7 +455,7 @@ public final class GuiBatchRunCoordinator {
* @throws NullPointerException if any argument is {@code null} * @throws NullPointerException if any argument is {@code null}
*/ */
public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) { public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null"); Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
Objects.requireNonNull(fingerprints, "fingerprints must not be null"); Objects.requireNonNull(fingerprints, "fingerprints must not be null");
if (isRunning()) { if (isRunning()) {
return false; return false;
@@ -437,46 +506,82 @@ public final class GuiBatchRunCoordinator {
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.", LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
configFilePath); configFilePath);
observerSummary.set(null); observerSummary.set(null);
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get; if (configurationFileLockPort.isPresent()) {
GuiBatchRunLaunchOutcome outcome; try {
try { configurationFileLockPort.get().acquireLock();
outcome = launcher.launch(configFilePath, observer, token); } catch (ConfigurationFileLockException e) {
if (outcome == null) { LOG.warn("GUI-Verarbeitungslauf: Konfigurationsdatei gesperrt Lauf abgebrochen: {}",
outcome = GuiBatchRunLaunchOutcome.failedAfterStart( e.getMessage());
"Launcher hat kein Ergebnis geliefert."); fxDispatcher.accept(() -> showLockErrorAlert());
finishRun(GuiBatchRunLaunchOutcome.rejected(
"Konfigurationsdatei gesperrt Lauf wurde abgebrochen."));
return;
} }
} catch (RuntimeException e) {
LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
e.getMessage(), e);
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
"Unerwarteter technischer Fehler: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
} }
finishRun(outcome);
try {
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
try {
outcome = launcher.launch(configFilePath, observer, token);
if (outcome == null) {
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
"Launcher hat kein Ergebnis geliefert.");
}
} catch (RuntimeException e) {
LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
e.getMessage(), e);
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
"Unerwarteter technischer Fehler: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
finishRun(outcome);
} finally {
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
}
} }
private void executeMiniRun(Path configFilePath, Set<DocumentFingerprint> fingerprintFilter) { private void executeMiniRun(Path configFilePath, Set<DocumentFingerprint> fingerprintFilter) {
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), " LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath); + "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
observerSummary.set(null); observerSummary.set(null);
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get; if (configurationFileLockPort.isPresent()) {
GuiBatchRunLaunchOutcome outcome; try {
try { configurationFileLockPort.get().acquireLock();
outcome = miniRunLauncher.launch(configFilePath, fingerprintFilter, observer, token); } catch (ConfigurationFileLockException e) {
if (outcome == null) { LOG.warn("GUI-Mini-Verarbeitungslauf: Konfigurationsdatei gesperrt Lauf abgebrochen: {}",
outcome = GuiBatchRunLaunchOutcome.failedAfterStart( e.getMessage());
"Mini-Run-Launcher hat kein Ergebnis geliefert."); fxDispatcher.accept(() -> showLockErrorAlert());
finishRun(GuiBatchRunLaunchOutcome.rejected(
"Konfigurationsdatei gesperrt Mini-Lauf wurde abgebrochen."));
return;
} }
} catch (RuntimeException e) {
LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
e.getMessage(), e);
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
"Unerwarteter technischer Fehler im Mini-Lauf: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
} }
finishRun(outcome);
try {
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
try {
outcome = miniRunLauncher.launch(configFilePath, fingerprintFilter, observer, token);
if (outcome == null) {
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
"Mini-Run-Launcher hat kein Ergebnis geliefert.");
}
} catch (RuntimeException e) {
LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
e.getMessage(), e);
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
"Unerwarteter technischer Fehler im Mini-Lauf: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
finishRun(outcome);
} finally {
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
}
} }
private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) { private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
@@ -611,6 +716,19 @@ public final class GuiBatchRunCoordinator {
historicalContext); historicalContext);
} }
private static void showLockErrorAlert() {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Verarbeitungslauf nicht möglich");
alert.setHeaderText("Konfigurationsdatei gesperrt");
alert.setContentText(
"Der Verarbeitungslauf konnte nicht gestartet werden, da die "
+ "Konfigurationsdatei nicht gesperrt werden konnte.\n\n"
+ "Mögliche Ursache: Der automatische Scheduler ist aktiv oder "
+ "ein anderer Prozess hält die Datei belegt.\n\n"
+ "Bitte stoppen Sie den Scheduler und versuchen Sie es erneut.");
alert.showAndWait();
}
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() { private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
return (configPath, fingerprint) -> Optional.empty(); return (configPath, fingerprint) -> Optional.empty();
} }
@@ -33,7 +33,7 @@ public record GuiBatchRunLaunchOutcome(
* Compact constructor normalising the failure message holder. * Compact constructor normalising the failure message holder.
*/ */
public GuiBatchRunLaunchOutcome { public GuiBatchRunLaunchOutcome {
failureMessage = failureMessage == null ? Optional.empty() : failureMessage; failureMessage = Objects.requireNonNullElse(failureMessage, Optional.empty());
} }
/** /**
@@ -88,16 +88,16 @@ public record GuiBatchRunResultRow(
} }
Objects.requireNonNull(fingerprint, "fingerprint must not be null"); Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(status, "status must not be null"); Objects.requireNonNull(status, "status must not be null");
finalFileName = finalFileName == null ? Optional.empty() : finalFileName; finalFileName = Objects.requireNonNullElse(finalFileName, Optional.empty());
correctedFileName = correctedFileName == null ? Optional.empty() : correctedFileName; correctedFileName = Objects.requireNonNullElse(correctedFileName, Optional.empty());
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate; resolvedDate = Objects.requireNonNullElse(resolvedDate, Optional.empty());
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning; aiReasoning = Objects.requireNonNullElse(aiReasoning, Optional.empty());
aiFailureMessage = aiFailureMessage == null ? Optional.empty() : aiFailureMessage; aiFailureMessage = Objects.requireNonNullElse(aiFailureMessage, Optional.empty());
Objects.requireNonNull(processingDuration, "processingDuration must not be null"); Objects.requireNonNull(processingDuration, "processingDuration must not be null");
if (processingDuration.isNegative()) { if (processingDuration.isNegative()) {
throw new IllegalArgumentException("processingDuration must not be negative"); throw new IllegalArgumentException("processingDuration must not be negative");
} }
historicalContext = historicalContext == null ? Optional.empty() : historicalContext; historicalContext = Objects.requireNonNullElse(historicalContext, Optional.empty());
} }
/** /**
@@ -41,8 +41,11 @@ import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSourceFile
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess; import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult; import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary; import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId; import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.beans.property.ReadOnlyBooleanWrapper;
@@ -108,6 +111,11 @@ import javafx.scene.layout.VBox;
* dafür, Hintergrundereignisse vor dem Callback auf den FX-Thread zu übertragen. * dafür, Hintergrundereignisse vor dem Callback auf den FX-Thread zu übertragen.
*/ */
public final class GuiBatchRunTab { public final class GuiBatchRunTab {
private static final String COPY_FAILED_LOG = "Manuelle Dateikopie fehlgeschlagen: {}";
private static final String RENAME_FAILED_LOG = "Manuelle Dateiumbenennung fehlgeschlagen: {}";
private static final String DIRTY_STATE_MSG = "Dateiname-Editor: Ungespeicherte Änderungen";
private static final Logger LOG = LogManager.getLogger(GuiBatchRunTab.class); private static final Logger LOG = LogManager.getLogger(GuiBatchRunTab.class);
@@ -193,6 +201,9 @@ public final class GuiBatchRunTab {
private final Button resetStatusButton = new Button("Status zurücksetzen"); private final Button resetStatusButton = new Button("Status zurücksetzen");
private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false); private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false);
/** {@code true} while the automatic scheduler is in any non-{@code STOPPED} state. */
private boolean schedulerActive = false;
/** Dateiname-Editor-Komponente im Detailbereich. */ /** Dateiname-Editor-Komponente im Detailbereich. */
private final FileNameEditorPane fileNameEditor = new FileNameEditorPane(); private final FileNameEditorPane fileNameEditor = new FileNameEditorPane();
@@ -230,7 +241,8 @@ public final class GuiBatchRunTab {
/** /**
* Erstellt den Verarbeitungslauf-Tab mit allen Verarbeitungs-, Mini-Lauf- und * Erstellt den Verarbeitungslauf-Tab mit allen Verarbeitungs-, Mini-Lauf- und
* Rücksetz-Fähigkeiten sowie dem Dateiname-Editor und der PDF-Vorschau. * Rücksetz-Fähigkeiten sowie dem Dateiname-Editor, der PDF-Vorschau und einem
* optionalen OS-Lock auf die Konfigurationsdatei.
* *
* @param launcherSupplier Supplier für den aktiven Batch-Lauf-Launcher; * @param launcherSupplier Supplier für den aktiven Batch-Lauf-Launcher;
* darf nicht null sein * darf nicht null sein
@@ -255,6 +267,9 @@ public final class GuiBatchRunTab {
* darf leeres Optional zurückliefern * darf leeres Optional zurückliefern
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner als * @param targetFolderSupplier Supplier für den konfigurierten Zielordner als
* Pfad-String; darf leeres Optional zurückliefern * Pfad-String; darf leeres Optional zurückliefern
* @param configurationFileLockPort optionaler OS-Lock auf die Konfigurationsdatei;
* wird vor jedem Lauf erworben und danach freigegeben;
* {@code null} wird als leer behandelt
*/ */
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier, public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier, Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
@@ -266,7 +281,8 @@ public final class GuiBatchRunTab {
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier, Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier, Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
Supplier<Optional<Path>> sourceFolderSupplier, Supplier<Optional<Path>> sourceFolderSupplier,
Supplier<Optional<String>> targetFolderSupplier) { Supplier<Optional<String>> targetFolderSupplier,
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null"); Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
Objects.requireNonNull(miniRunLauncherSupplier, "miniRunLauncherSupplier must not be null"); Objects.requireNonNull(miniRunLauncherSupplier, "miniRunLauncherSupplier must not be null");
Objects.requireNonNull(resetPortSupplier, "resetPortSupplier must not be null"); Objects.requireNonNull(resetPortSupplier, "resetPortSupplier must not be null");
@@ -285,6 +301,8 @@ public final class GuiBatchRunTab {
this.targetFolderSupplier = Objects.requireNonNull( this.targetFolderSupplier = Objects.requireNonNull(
targetFolderSupplier, "targetFolderSupplier must not be null"); targetFolderSupplier, "targetFolderSupplier must not be null");
Optional<ConfigurationFileLockPort> effectiveLockPort =
Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
this.coordinator = new GuiBatchRunCoordinator( this.coordinator = new GuiBatchRunCoordinator(
(configPath, observer, token) -> (configPath, observer, token) ->
launcherSupplier.get().launch(configPath, observer, token), launcherSupplier.get().launch(configPath, observer, token),
@@ -293,7 +311,8 @@ public final class GuiBatchRunTab {
(configPath, fingerprints) -> (configPath, fingerprints) ->
resetPortSupplier.get().reset(configPath, fingerprints), resetPortSupplier.get().reset(configPath, fingerprints),
new CoordinatorListener(), new CoordinatorListener(),
historicalDocumentContextPortSupplier.get()); historicalDocumentContextPortSupplier.get(),
effectiveLockPort);
this.tab.setClosable(false); this.tab.setClosable(false);
this.tab.setContent(buildContent()); this.tab.setContent(buildContent());
@@ -312,6 +331,51 @@ public final class GuiBatchRunTab {
updateButtonStates(); updateButtonStates();
} }
/**
* Rückwärtskompatible Variante ohne OS-Lock auf die Konfigurationsdatei.
* <p>
* Alle bestehenden Aufrufer, die vor der Lock-Port-Erweiterung erstellt wurden,
* nutzen diesen Konstruktor. Er delegiert an den kanonischen Konstruktor mit
* {@code configurationFileLockPort = Optional.empty()}.
*
* @param launcherSupplier Supplier für den aktiven Batch-Lauf-Launcher;
* darf nicht null sein
* @param miniRunLauncherSupplier Supplier für den Mini-Lauf-Launcher;
* darf nicht null sein
* @param resetPortSupplier Supplier für den Rücksetz-Port;
* darf nicht null sein
* @param configPathSupplier Supplier für den letzten gespeicherten
* Konfigurationspfad; darf null zurückliefern
* @param savedConfigurationReadyCheck Prüfung vor jedem Startversuch; darf nicht
* null sein
* @param onRunStateChanged Callback wenn das Lauf-Flag kippt; darf nicht
* null sein
* @param manualFileRenamePortSupplier Supplier für den manuellen Umbenennungs-Port;
* darf nicht null sein
* @param manualFileCopyPortSupplier Supplier für den manuellen Kopier-Port;
* darf nicht null sein
* @param historicalDocumentContextPortSupplier Supplier für den historischen Kontext-Port;
* darf nicht null sein
* @param sourceFolderSupplier Supplier für den konfigurierten Quellordner
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner
*/
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
Supplier<GuiResetDocumentStatusPort> resetPortSupplier,
Supplier<Path> configPathSupplier,
BooleanSupplier savedConfigurationReadyCheck,
Runnable onRunStateChanged,
Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
Supplier<Optional<Path>> sourceFolderSupplier,
Supplier<Optional<String>> targetFolderSupplier) {
this(launcherSupplier, miniRunLauncherSupplier, resetPortSupplier, configPathSupplier,
savedConfigurationReadyCheck, onRunStateChanged, manualFileRenamePortSupplier,
manualFileCopyPortSupplier, historicalDocumentContextPortSupplier,
sourceFolderSupplier, targetFolderSupplier, Optional.empty());
}
/** /**
* Rückwärtskompatible Variante für Aufrufer ohne Mini-Lauf- oder Rücksetz-Fähigkeiten. * Rückwärtskompatible Variante für Aufrufer ohne Mini-Lauf- oder Rücksetz-Fähigkeiten.
* *
@@ -437,6 +501,25 @@ public final class GuiBatchRunTab {
return askDiscardFilenameChanges(); return askDiscardFilenameChanges();
} }
/**
* Aktualisiert den Tab-Zustand anhand des aktuellen Scheduler-Status.
* <p>
* Deaktiviert den Starten-Button und setzt einen erklärenden Tooltip, solange
* der Scheduler aktiv ist. Wenn der Scheduler gestoppt ist, wird der normale
* Button-Zustand wiederhergestellt (Starten erlaubt sofern kein Lauf läuft).
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
*/
public void updateSchedulerState(SchedulerStatus status) {
schedulerActive = status.state().isActive();
startButton.setDisable(runningProperty.get() || schedulerActive);
startButton.setTooltip(new Tooltip(schedulerActive
? "Manuelle Läufe sind während aktivem Scheduler nicht möglich."
: GuiTooltipTexts.BATCHRUN_STARTEN));
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Paket-private Accessor für Tests // Paket-private Accessor für Tests
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -527,19 +610,22 @@ public final class GuiBatchRunTab {
// Selektions-Aktions-Buttons unterhalb der Tabelle (linke Spalte) // Selektions-Aktions-Buttons unterhalb der Tabelle (linke Spalte)
reprocessButton.setId("batch-run-reprocess"); reprocessButton.setId("batch-run-reprocess");
reprocessButton.setOnAction(event -> handleReprocessSelected()); reprocessButton.setOnAction(event -> handleReprocessSelected());
reprocessButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ERNEUT_VERARBEITEN));
resetStatusButton.setId("batch-run-reset-status"); resetStatusButton.setId("batch-run-reset-status");
resetStatusButton.setOnAction(event -> handleResetSelected()); resetStatusButton.setOnAction(event -> handleResetSelected());
resetStatusButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STATUS_ZURUECKSETZEN));
HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton); HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton);
selectionButtonBar.setAlignment(Pos.CENTER_LEFT); selectionButtonBar.setAlignment(Pos.CENTER_LEFT);
selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, SECONDARY_SPACING / 2, 0)); selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2.0, 0, SECONDARY_SPACING / 2.0, 0));
// Meldungsbereich unterhalb der Selektions-Buttons (linke Spalte) // Meldungsbereich unterhalb der Selektions-Buttons (linke Spalte)
messageArea.setId("batch-run-message-area"); messageArea.setId("batch-run-message-area");
messageArea.setEditable(false); messageArea.setEditable(false);
messageArea.setWrapText(true); messageArea.setWrapText(true);
messageArea.setPrefRowCount(3); messageArea.setPrefRowCount(3);
messageArea.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MESSAGE_AREA));
// Hinweisbereich erst einblenden wenn eine Meldung vorliegt // Hinweisbereich erst einblenden wenn eine Meldung vorliegt
messageArea.setVisible(false); messageArea.setVisible(false);
messageArea.setManaged(false); messageArea.setManaged(false);
@@ -600,12 +686,14 @@ public final class GuiBatchRunTab {
masterCheckBox.setId("batch-run-master-checkbox"); masterCheckBox.setId("batch-run-master-checkbox");
masterCheckBox.setOnAction(e -> handleMasterCheckBoxAction()); masterCheckBox.setOnAction(e -> handleMasterCheckBoxAction());
masterCheckBox.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MASTER_CHECKBOX));
checkboxCol.setGraphic(masterCheckBox); checkboxCol.setGraphic(masterCheckBox);
checkboxCol.setCellFactory(col -> new CheckBoxCell()); checkboxCol.setCellFactory(col -> new CheckBoxCell());
checkboxCol.setEditable(true); checkboxCol.setEditable(true);
TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>("Status"); TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>();
iconCol.setGraphic(columnHeader("Status", GuiTooltipTexts.BATCHRUN_COL_STATUS));
iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon())); iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon()));
iconCol.setPrefWidth(64); iconCol.setPrefWidth(64);
iconCol.setCellFactory(col -> new TableCell<GuiBatchRunResultRow, String>() { iconCol.setCellFactory(col -> new TableCell<GuiBatchRunResultRow, String>() {
@@ -636,11 +724,13 @@ public final class GuiBatchRunTab {
} }
}); });
TableColumn<GuiBatchRunResultRow, String> nameCol = new TableColumn<>("Originaldateiname"); TableColumn<GuiBatchRunResultRow, String> nameCol = new TableColumn<>();
nameCol.setGraphic(columnHeader("Originaldateiname", GuiTooltipTexts.BATCHRUN_COL_ORIGINALDATEINAME));
nameCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().originalFileName())); nameCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().originalFileName()));
nameCol.setPrefWidth(280); nameCol.setPrefWidth(280);
TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>("Neuer Dateiname"); TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>();
newNameCol.setGraphic(columnHeader("Neuer Dateiname", GuiTooltipTexts.BATCHRUN_COL_NEUER_DATEINAME));
newNameCol.setCellValueFactory(data -> { newNameCol.setCellValueFactory(data -> {
GuiBatchRunResultRow row = data.getValue(); GuiBatchRunResultRow row = data.getValue();
if (row.resetPending()) { if (row.resetPending()) {
@@ -650,14 +740,16 @@ public final class GuiBatchRunTab {
}); });
newNameCol.setPrefWidth(280); newNameCol.setPrefWidth(280);
TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>("Datum"); TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>();
dateCol.setGraphic(columnHeader("Datum", GuiTooltipTexts.BATCHRUN_COL_DATUM));
dateCol.setCellValueFactory(data -> new SimpleStringProperty( dateCol.setCellValueFactory(data -> new SimpleStringProperty(
data.getValue().resolvedDate() data.getValue().resolvedDate()
.map(DateTimeFormatter.ISO_LOCAL_DATE::format) .map(DateTimeFormatter.ISO_LOCAL_DATE::format)
.orElse(EMPTY_CELL_TEXT))); .orElse(EMPTY_CELL_TEXT)));
dateCol.setPrefWidth(100); dateCol.setPrefWidth(100);
TableColumn<GuiBatchRunResultRow, String> durationCol = new TableColumn<>("Dauer"); TableColumn<GuiBatchRunResultRow, String> durationCol = new TableColumn<>();
durationCol.setGraphic(columnHeader("Dauer", GuiTooltipTexts.BATCHRUN_COL_DAUER));
durationCol.setCellValueFactory(data -> new SimpleStringProperty( durationCol.setCellValueFactory(data -> new SimpleStringProperty(
formatDuration(data.getValue().processingDuration()))); formatDuration(data.getValue().processingDuration())));
durationCol.setPrefWidth(80); durationCol.setPrefWidth(80);
@@ -733,7 +825,7 @@ public final class GuiBatchRunTab {
return; return;
} }
fileNameEditor.discardChanges(); fileNameEditor.discardChanges();
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung Benutzer hat verworfen"); LOG.debug(DIRTY_STATE_MSG);
} }
// Neue Zeile laden // Neue Zeile laden
@@ -870,55 +962,55 @@ public final class GuiBatchRunTab {
*/ */
private void handleCopyResult(ManualFileCopyResult result, GuiBatchRunResultRow row) { private void handleCopyResult(ManualFileCopyResult result, GuiBatchRunResultRow row) {
switch (result) { switch (result) {
case ManualFileCopySuccess success -> { case ManualFileCopySuccess success -> applyCopySuccess(success, row);
LOG.info("Manuelle Dateikopie erfolgreich: {} → {} (Suffix: {})", case ManualFileCopyNoOpIdenticalTarget noOp -> applyCopyNoOpIdentical(noOp, row);
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);
String msg = "Datei kopiert und gespeichert: " + success.appliedFileName();
if (success.conflictSuffixApplied()) {
msg += " (Suffix wegen Namenskonflikt angehängt)";
}
showMessage(msg);
refreshAggregateCountersFromItems();
}
case ManualFileCopyNoOpIdenticalTarget noOp -> {
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);
showMessage("Identische Datei bereits vorhanden Status auf SUCCESS gesetzt");
refreshAggregateCountersFromItems();
}
case ManualFileCopyDocumentNotFound notFound -> { case ManualFileCopyDocumentNotFound notFound -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", notFound.reason()); LOG.warn(COPY_FAILED_LOG, notFound.reason());
showMessage("Fehler: Dokument nicht gefunden " + notFound.reason()); showMessage("Fehler: Dokument nicht gefunden " + notFound.reason());
} }
case ManualFileCopyInvalidState invalidState -> { case ManualFileCopyInvalidState invalidState -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", invalidState.reason()); LOG.warn(COPY_FAILED_LOG, invalidState.reason());
showMessage("Fehler: Ungültiger Dokumentstatus " + invalidState.reason()); showMessage("Fehler: Ungültiger Dokumentstatus " + invalidState.reason());
} }
case ManualFileCopyFileSystemFailure fsFail -> { case ManualFileCopyFileSystemFailure fsFail -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", fsFail.message()); LOG.warn(COPY_FAILED_LOG, fsFail.message());
showMessage("Dateisystemfehler: " + fsFail.message()); showMessage("Dateisystemfehler: " + fsFail.message());
} }
case ManualFileCopyPersistenceFailure persistFail -> { case ManualFileCopyPersistenceFailure persistFail -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", persistFail.message()); LOG.warn(COPY_FAILED_LOG, persistFail.message());
showMessage("Persistenzfehler (Zielkopie wurde zurückgerollt): " showMessage("Persistenzfehler (Zielkopie wurde zurückgerollt): " + persistFail.message());
+ persistFail.message());
} }
} }
} }
private void applyCopySuccess(ManualFileCopySuccess success, GuiBatchRunResultRow row) {
LOG.info("Manuelle Dateikopie erfolgreich: {} → {} (Suffix: {})",
row.originalFileName(), success.appliedFileName(), success.conflictSuffixApplied());
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, success.appliedFileName());
currentlySelectedRow = updatedRow;
fileNameEditor.clearDirtyState();
upsertResultRowByFingerprint(updatedRow);
fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
String msg = "Datei kopiert und gespeichert: " + success.appliedFileName();
if (success.conflictSuffixApplied()) {
msg += " (Suffix wegen Namenskonflikt angehängt)";
}
showMessage(msg);
refreshAggregateCountersFromItems();
}
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);
fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
showMessage("Identische Datei bereits vorhanden Status auf SUCCESS gesetzt");
refreshAggregateCountersFromItems();
}
/** /**
* Baut eine neue Zeilen-Sicht für ein Dokument, das per manueller Dateikopie auf * Baut eine neue Zeilen-Sicht für ein Dokument, das per manueller Dateikopie auf
* {@code SUCCESS} gehoben wurde. Status, korrigierter Dateiname und das Zurücksetzen * {@code SUCCESS} gehoben wurde. Status, korrigierter Dateiname und das Zurücksetzen
@@ -1018,24 +1110,24 @@ public final class GuiBatchRunTab {
noOp.existingFileName()); noOp.existingFileName());
} }
case ManualFileRenameDocumentNotFound notFound -> { case ManualFileRenameDocumentNotFound notFound -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", notFound.reason()); LOG.warn(RENAME_FAILED_LOG, notFound.reason());
showMessage("Fehler: Dokument nicht gefunden " + notFound.reason()); showMessage("Fehler: Dokument nicht gefunden " + notFound.reason());
} }
case ManualFileRenameInvalidState invalidState -> { case ManualFileRenameInvalidState invalidState -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", invalidState.reason()); LOG.warn(RENAME_FAILED_LOG, invalidState.reason());
showMessage("Fehler: Ungültiger Dokumentstatus " + invalidState.reason()); showMessage("Fehler: Ungültiger Dokumentstatus " + invalidState.reason());
} }
case ManualFileRenameSourceFileMissing sourceMissing -> { case ManualFileRenameSourceFileMissing sourceMissing -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", LOG.warn(RENAME_FAILED_LOG,
sourceMissing.expectedFileName()); sourceMissing.expectedFileName());
showMessage("Zieldatei nicht gefunden Umbenennung nicht möglich"); showMessage("Zieldatei nicht gefunden Umbenennung nicht möglich");
} }
case ManualFileRenameFileSystemFailure fsFail -> { case ManualFileRenameFileSystemFailure fsFail -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", fsFail.message()); LOG.warn(RENAME_FAILED_LOG, fsFail.message());
showMessage("Dateisystemfehler: " + fsFail.message()); showMessage("Dateisystemfehler: " + fsFail.message());
} }
case ManualFileRenamePersistenceFailure persistFail -> { case ManualFileRenamePersistenceFailure persistFail -> {
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", persistFail.message()); LOG.warn(RENAME_FAILED_LOG, persistFail.message());
showMessage("Persistenzfehler (Dateisystem wurde zurückgerollt): " showMessage("Persistenzfehler (Dateisystem wurde zurückgerollt): "
+ persistFail.message()); + persistFail.message());
} }
@@ -1145,14 +1237,16 @@ public final class GuiBatchRunTab {
// Lauf-Steuerungs-Buttons // Lauf-Steuerungs-Buttons
startButton.setId("batch-run-start"); startButton.setId("batch-run-start");
startButton.setOnAction(event -> handleStart()); startButton.setOnAction(event -> handleStart());
startButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STARTEN));
cancelButton.setId("batch-run-cancel"); cancelButton.setId("batch-run-cancel");
cancelButton.setOnAction(event -> requestCancellation()); cancelButton.setOnAction(event -> requestCancellation());
cancelButton.setDisable(true); cancelButton.setDisable(true);
cancelButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ABBRECHEN));
HBox runButtonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton); HBox runButtonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
runButtonBar.setAlignment(Pos.CENTER_LEFT); runButtonBar.setAlignment(Pos.CENTER_LEFT);
runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, 0, 0)); runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2.0, 0, 0, 0));
return runButtonBar; return runButtonBar;
} }
@@ -1174,7 +1268,7 @@ public final class GuiBatchRunTab {
return; return;
} }
fileNameEditor.discardChanges(); fileNameEditor.discardChanges();
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung Benutzer hat verworfen"); LOG.debug(DIRTY_STATE_MSG);
} }
if (!savedConfigurationReadyCheck.getAsBoolean()) { if (!savedConfigurationReadyCheck.getAsBoolean()) {
showMessage(NO_SAVED_CONFIGURATION_HINT); showMessage(NO_SAVED_CONFIGURATION_HINT);
@@ -1228,7 +1322,7 @@ public final class GuiBatchRunTab {
return; return;
} }
fileNameEditor.discardChanges(); fileNameEditor.discardChanges();
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung Benutzer hat verworfen"); LOG.debug(DIRTY_STATE_MSG);
} }
if (!savedConfigurationReadyCheck.getAsBoolean()) { if (!savedConfigurationReadyCheck.getAsBoolean()) {
showMessage(NO_SAVED_CONFIGURATION_HINT); showMessage(NO_SAVED_CONFIGURATION_HINT);
@@ -1403,7 +1497,7 @@ public final class GuiBatchRunTab {
private void updateButtonStates() { private void updateButtonStates() {
boolean running = runningProperty.get(); boolean running = runningProperty.get();
startButton.setDisable(running); startButton.setDisable(running || schedulerActive);
if (!running) { if (!running) {
cancelButton.setDisable(true); cancelButton.setDisable(true);
} else { } else {
@@ -1439,6 +1533,21 @@ public final class GuiBatchRunTab {
// statusColor() wurde zugunsten von ProcessingStatusPresentation.cssColorFor() entfernt. // statusColor() wurde zugunsten von ProcessingStatusPresentation.cssColorFor() entfernt.
/**
* Erzeugt ein Label für den Spaltenkopf einer TableColumn mit Tooltip.
* Wird anstelle von {@code column.setText()} verwendet, da TableColumn
* kein direktes {@code setTooltip()} unterstützt.
*
* @param title sichtbarer Spaltentext; darf nicht leer sein
* @param tooltip Tooltip-Text; darf nicht leer sein
* @return ein Label mit gesetztem Tooltip
*/
private static Label columnHeader(String title, String tooltip) {
Label label = new Label(title);
label.setTooltip(new Tooltip(tooltip));
return label;
}
private static String formatDuration(Duration duration) { private static String formatDuration(Duration duration) {
double seconds = duration.toMillis() / 1000.0; double seconds = duration.toMillis() / 1000.0;
if (seconds < 10) { if (seconds < 10) {
@@ -1458,35 +1567,12 @@ public final class GuiBatchRunTab {
return builder.toString(); return builder.toString();
} }
if (row.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED) { if (row.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED) {
builder.append('\n'); return appendSkippedAlreadyProcessed(builder, row);
row.historicalContext().ifPresentOrElse(ctx -> {
ctx.lastSuccessInstant().ifPresentOrElse(
instant -> builder.append("Bereits erfolgreich verarbeitet am ")
.append(DETAIL_DATE_FORMAT.format(
instant.atZone(ZoneId.systemDefault())))
.append('.'),
() -> builder.append("Bereits erfolgreich verarbeitet."));
ctx.lastTargetFileName().ifPresent(name ->
builder.append('\n').append("Zieldatei: ").append(name).append('.'));
}, () -> builder.append("Bereits erfolgreich verarbeitet."));
return builder.toString();
} }
if (row.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE) { if (row.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE) {
builder.append('\n'); return appendSkippedFinalFailure(builder, row);
row.historicalContext().ifPresentOrElse(ctx ->
ctx.lastFailureInstant().ifPresentOrElse(
instant -> builder.append("Endg\u00fcltig fehlgeschlagen am ")
.append(DETAIL_DATE_FORMAT.format(
instant.atZone(ZoneId.systemDefault())))
.append(". Erneute Verarbeitung nur nach Reset m\u00f6glich."),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich.")),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
return builder.toString();
} }
if (row.status() == DocumentCompletionStatus.FAILED_PERMANENT) { if (row.status() == DocumentCompletionStatus.FAILED_PERMANENT) {
// Erweiterter Erkl\u00e4rungstext gem\u00e4\u00df Spezifikation #51 \u2013 dauerhaft fehlgeschlagen
builder.append('\n').append(ProcessingStatusPresentation.DETAIL_TEXT_FAILED_PERMANENT); builder.append('\n').append(ProcessingStatusPresentation.DETAIL_TEXT_FAILED_PERMANENT);
row.aiFailureMessage().ifPresent(msg -> row.aiFailureMessage().ifPresent(msg ->
builder.append("\n\nFehlerdetail: ") builder.append("\n\nFehlerdetail: ")
@@ -1507,6 +1593,34 @@ public final class GuiBatchRunTab {
return builder.toString(); return builder.toString();
} }
private static String appendSkippedAlreadyProcessed(StringBuilder builder, GuiBatchRunResultRow row) {
builder.append('\n');
row.historicalContext().ifPresentOrElse(ctx -> {
ctx.lastSuccessInstant().ifPresentOrElse(
instant -> builder.append("Bereits erfolgreich verarbeitet am ")
.append(DETAIL_DATE_FORMAT.format(instant.atZone(ZoneId.systemDefault())))
.append('.'),
() -> builder.append("Bereits erfolgreich verarbeitet."));
ctx.lastTargetFileName().ifPresent(name ->
builder.append('\n').append("Zieldatei: ").append(name).append('.'));
}, () -> builder.append("Bereits erfolgreich verarbeitet."));
return builder.toString();
}
private static String appendSkippedFinalFailure(StringBuilder builder, GuiBatchRunResultRow row) {
builder.append('\n');
row.historicalContext().ifPresentOrElse(ctx ->
ctx.lastFailureInstant().ifPresentOrElse(
instant -> builder.append("Endg\u00fcltig fehlgeschlagen am ")
.append(DETAIL_DATE_FORMAT.format(instant.atZone(ZoneId.systemDefault())))
.append(". Erneute Verarbeitung nur nach Reset m\u00f6glich."),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich.")),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
return builder.toString();
}
private static GuiBatchRunLaunchOutcome rejectingMiniLaunch( private static GuiBatchRunLaunchOutcome rejectingMiniLaunch(
Path p, Set<DocumentFingerprint> f, Path p, Set<DocumentFingerprint> f,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver o, de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver o,
@@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@@ -19,13 +20,21 @@ import org.apache.pdfbox.rendering.PDFRenderer;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils; import javafx.embed.swing.SwingFXUtils;
import javafx.geometry.Bounds;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import javafx.scene.Cursor;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator; import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.ImageView; import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.Region; import javafx.scene.layout.Region;
@@ -36,10 +45,21 @@ import javafx.scene.layout.VBox;
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei. * Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
* *
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis * <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
* in einer {@link ImageView} an. Die Anzeige ist vollständig eingepasst (fit-to-view): * in einer {@link ImageView} an. Im Fit-to-View-Modus (Standardzustand) sind
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} sind an die Größe des * {@code fitWidth} und {@code fitHeight} der {@link ImageView} an die Größe des
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das * umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
* Seitenverhältnis. Es entstehen weder Scrollbalken noch Zoom-Artefakte. * Seitenverhältnis. Die Seite füllt den verfügbaren Bereich ohne Scrollbalken.
*
* <p><strong>Mausrad-Zoom:</strong> Strg + Mausrad ändert den Zoomfaktor in Stufen von
* 10 % pro Raste (Bereich {@value #ZOOM_MIN}{@value #ZOOM_MAX}, d. h. 10 %500 %).
* Beim ersten manuellen Zoom wird der Fit-to-View-Modus verlassen und ein
* {@link ScrollPane} übernimmt das Scrollen. Das Laden einer neuen Datei setzt den
* Zoom automatisch auf Fit-to-View zurück.
*
* <p><strong>Grab &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 * <p>Das Laden der PDF-Datei und das Rendering einzelner Seiten erfolgt auf einem
* dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX * dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX
@@ -77,6 +97,18 @@ public final class PdfPreviewPane {
/** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */ /** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
private static final float RENDER_DPI = 120f; private static final float RENDER_DPI = 120f;
/** Minimaler Zoomfaktor (10 %). */
static final double ZOOM_MIN = 0.10;
/** Maximaler Zoomfaktor (500 %). */
static final double ZOOM_MAX = 5.00;
/** Zoom-Schrittgröße pro Mausrad-Raste (10 %). */
private static final double ZOOM_STEP = 0.10;
/** Typischer vertikaler Scroll-Delta pro Mausrad-Raste. */
private static final double ZOOM_NOTCH_THRESHOLD = 40.0;
private final VBox root = new VBox(4); private final VBox root = new VBox(4);
private final StackPane viewStack = new StackPane(); private final StackPane viewStack = new StackPane();
private final ImageView imageView = new ImageView(); private final ImageView imageView = new ImageView();
@@ -86,6 +118,35 @@ public final class PdfPreviewPane {
private final Button prevButton = new Button("◀ Vorherige"); private final Button prevButton = new Button("◀ Vorherige");
private final Button nextButton = new Button("Nächste ▶"); private final Button nextButton = new Button("Nächste ▶");
private final Label sectionTitle = new Label("PDF-Vorschau"); private final Label sectionTitle = new Label("PDF-Vorschau");
private final ScrollPane scrollPane = new ScrollPane(viewStack);
/** Aktueller Zoomfaktor; 1.0 entspricht der natürlichen Viewport-Breite. */
private double zoomLevel = 1.0;
/** Akkumulator für sub-Rasten-Scroll-Deltas. */
private double zoomAccumulator = 0.0;
/**
* Referenzbreite für die manuelle Zoom-Skalierung; gilt
* {@code imageView.fitWidth = naturalViewportWidth × zoomLevel} im manuellen
* Zoom-Modus. Beim Verlassen des Fit-Modus wird der Wert auf die natürliche
* Bildbreite gesetzt, sodass {@code zoomLevel = 1.0} der pixel-genauen
* Originalgröße entspricht und {@code zoomLevel} damit gleich dem visuellen
* Skalierungsfaktor ist. {@code 0.0} bedeutet Fit-to-View-Modus ist aktiv.
*/
private double naturalViewportWidth = 0.0;
/** X-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
private double panStartX = -1;
/** Y-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
private double panStartY = -1;
/** Horizontaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
private double panStartHvalue = 0.0;
/** Vertikaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
private double panStartVvalue = 0.0;
/** /**
* Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung * Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
@@ -110,18 +171,18 @@ public final class PdfPreviewPane {
/** /**
* Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread. * Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread.
* {@code null} wenn kein Dokument geöffnet ist. * Leerer Referenzwert wenn kein Dokument geöffnet ist.
*/ */
private volatile PDDocument currentDocument = null; private final AtomicReference<PDDocument> currentDocument = new AtomicReference<>();
/** /**
* Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread. * Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread.
* {@code null} wenn kein Dokument geöffnet ist. * Leerer Referenzwert wenn kein Dokument geöffnet ist.
*/ */
private volatile PDFRenderer currentRenderer = null; private final AtomicReference<PDFRenderer> currentRenderer = new AtomicReference<>();
/** Aktuell geladene Quelldatei; null wenn keine Selektion vorliegt. */ /** Aktuell geladene Quelldatei; leerer Referenzwert wenn keine Selektion vorliegt. */
private volatile Path currentSourceFile = null; private final AtomicReference<Path> currentSourceFile = new AtomicReference<>();
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */ /** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
private volatile int currentPage = 0; private volatile int currentPage = 0;
@@ -162,13 +223,48 @@ public final class PdfPreviewPane {
StackPane.setAlignment(imageView, Pos.CENTER); StackPane.setAlignment(imageView, Pos.CENTER);
StackPane.setAlignment(overlayLabel, Pos.CENTER); StackPane.setAlignment(overlayLabel, Pos.CENTER);
StackPane.setAlignment(progressIndicator, Pos.CENTER); StackPane.setAlignment(progressIndicator, Pos.CENTER);
VBox.setVgrow(viewStack, Priority.ALWAYS);
scrollPane.setId("pdf-preview-scroll-pane");
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
// 32c: Verhindert, dass ScrollPane und StackPane beim manuellen Zoom mitwachsen
scrollPane.setPrefSize(0, 0);
viewStack.setMinSize(0, 0);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
// Strg + Mausrad Zoom; ohne Strg normales Scrollen
scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
if (event.isControlDown()) {
accumulateAndApplyZoomDelta(event.getDeltaY());
event.consume();
}
});
// Grab & Pan im manuellen Zoom-Modus mit Maus verschiebbar
viewStack.addEventHandler(MouseEvent.MOUSE_PRESSED, this::onPanMousePressed);
viewStack.addEventHandler(MouseEvent.MOUSE_DRAGGED, this::onPanMouseDragged);
viewStack.addEventHandler(MouseEvent.MOUSE_RELEASED, this::onPanMouseReleased);
// viewStack ist immer mindestens so groß wie der Viewport. Ist der Inhalt
// (ImageView) kleiner als der Viewport, sorgt diese Mindestgröße zusammen
// mit StackPane.Pos.CENTER dafür, dass die ImageView automatisch zentriert
// wird ohne manuelle setHvalue/setVvalue-Eingriffe. Ist der Inhalt größer,
// bleibt die Mindestgröße wirkungslos und der ScrollPane scrollt normal.
scrollPane.viewportBoundsProperty().addListener((obs, old, bounds) -> {
viewStack.setMinWidth(bounds.getWidth());
viewStack.setMinHeight(bounds.getHeight());
});
prevButton.setId("pdf-preview-prev-button"); prevButton.setId("pdf-preview-prev-button");
prevButton.setOnAction(e -> navigateToPreviousPage()); prevButton.setOnAction(e -> navigateToPreviousPage());
Tooltip prevTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_VORHERIGE_SEITE);
prevTooltip.setShowDelay(Duration.millis(300));
prevButton.setTooltip(prevTooltip);
nextButton.setId("pdf-preview-next-button"); nextButton.setId("pdf-preview-next-button");
nextButton.setOnAction(e -> navigateToNextPage()); nextButton.setOnAction(e -> navigateToNextPage());
Tooltip nextTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_NAECHSTE_SEITE);
nextTooltip.setShowDelay(Duration.millis(300));
nextButton.setTooltip(nextTooltip);
pageLabel.setId("pdf-preview-page-label"); pageLabel.setId("pdf-preview-page-label");
pageLabel.setStyle("-fx-text-fill: #555555;"); pageLabel.setStyle("-fx-text-fill: #555555;");
@@ -177,7 +273,7 @@ public final class PdfPreviewPane {
navBar.setAlignment(Pos.CENTER); navBar.setAlignment(Pos.CENTER);
navBar.setPadding(new Insets(4, 0, 4, 0)); navBar.setPadding(new Insets(4, 0, 4, 0));
root.getChildren().addAll(sectionTitle, viewStack, navBar); root.getChildren().addAll(sectionTitle, scrollPane, navBar);
root.setPadding(new Insets(4, 0, 0, 0)); root.setPadding(new Insets(4, 0, 0, 0));
showPlaceholder(); showPlaceholder();
@@ -208,10 +304,11 @@ public final class PdfPreviewPane {
clear(); clear();
return; return;
} }
currentSourceFile = sourceFile; currentSourceFile.set(sourceFile);
currentPage = 0; currentPage = 0;
totalPages = -1; totalPages = -1;
pageCache.clear(); pageCache.clear();
resetToFitView();
requestLoad(sourceFile); requestLoad(sourceFile);
} }
@@ -222,7 +319,7 @@ public final class PdfPreviewPane {
* Muss auf dem JavaFX Application Thread aufgerufen werden. * Muss auf dem JavaFX Application Thread aufgerufen werden.
*/ */
public void clear() { public void clear() {
currentSourceFile = null; currentSourceFile.set(null);
currentPage = 0; currentPage = 0;
totalPages = -1; totalPages = -1;
pageCache.clear(); pageCache.clear();
@@ -230,6 +327,7 @@ public final class PdfPreviewPane {
currentRequestSequence.incrementAndGet(); currentRequestSequence.incrementAndGet();
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird // Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
executor.submit(this::closeCurrentDocumentOnWorker); executor.submit(this::closeCurrentDocumentOnWorker);
resetToFitView();
imageView.setImage(null); imageView.setImage(null);
showPlaceholder(); showPlaceholder();
updateNavigationButtons(); updateNavigationButtons();
@@ -287,6 +385,16 @@ public final class PdfPreviewPane {
return progressIndicator; return progressIndicator;
} }
/** Visible for tests. */
ScrollPane scrollPane() {
return scrollPane;
}
/** Visible for tests. */
double zoomLevel() {
return zoomLevel;
}
// --- Navigation ----------------------------------------------------------- // --- Navigation -----------------------------------------------------------
private void navigateToPreviousPage() { private void navigateToPreviousPage() {
@@ -365,12 +473,13 @@ public final class PdfPreviewPane {
try { try {
PDDocument doc = Loader.loadPDF(ioFile); PDDocument doc = Loader.loadPDF(ioFile);
currentDocument = doc; currentDocument.set(doc);
currentRenderer = new PDFRenderer(doc); PDFRenderer renderer = new PDFRenderer(doc);
currentRenderer.set(renderer);
int pages = Math.max(1, doc.getNumberOfPages()); int pages = Math.max(1, doc.getNumberOfPages());
BufferedImage buffered = BufferedImage buffered =
currentRenderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB); renderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
Image fxImage = SwingFXUtils.toFXImage(buffered, null); Image fxImage = SwingFXUtils.toFXImage(buffered, null);
final int totalPagesFinal = pages; final int totalPagesFinal = pages;
@@ -406,7 +515,7 @@ public final class PdfPreviewPane {
* @param seq die Sequenznummer dieser Anforderung * @param seq die Sequenznummer dieser Anforderung
*/ */
private void renderPageOnWorker(int page, long seq) { private void renderPageOnWorker(int page, long seq) {
PDFRenderer renderer = currentRenderer; PDFRenderer renderer = currentRenderer.get();
if (renderer == null) { if (renderer == null) {
// Dokument wurde zwischenzeitlich geschlossen nichts zu tun // Dokument wurde zwischenzeitlich geschlossen nichts zu tun
return; return;
@@ -435,9 +544,8 @@ public final class PdfPreviewPane {
* auf dem Worker-Thread und ist idempotent. * auf dem Worker-Thread und ist idempotent.
*/ */
private void closeCurrentDocumentOnWorker() { private void closeCurrentDocumentOnWorker() {
PDDocument doc = currentDocument; PDDocument doc = currentDocument.getAndSet(null);
currentDocument = null; currentRenderer.set(null);
currentRenderer = null;
if (doc != null) { if (doc != null) {
try { try {
doc.close(); doc.close();
@@ -463,6 +571,217 @@ public final class PdfPreviewPane {
}); });
} }
// --- Grab & Pan -----------------------------------------------------------
/**
* Startet die Pan-Geste. Speichert die Startposition und den aktuellen Scroll-Zustand.
* Nur aktiv wenn der manuelle Zoom-Modus eingeschaltet ist.
*
* @param event das Maus-Pressed-Ereignis
*/
private void onPanMousePressed(MouseEvent event) {
if (scrollPane.isFitToWidth()) {
return; // Im Fit-Modus kein Pan nötig
}
panStartX = event.getScreenX();
panStartY = event.getScreenY();
panStartHvalue = scrollPane.getHvalue();
panStartVvalue = scrollPane.getVvalue();
viewStack.setCursor(Cursor.CLOSED_HAND);
event.consume();
}
/**
* Verschiebt den Viewport relativ zur Startposition der Pan-Geste.
* Die Scrolldelta wird auf die scrollbaren Bereiche des Inhalts normiert.
*
* @param event das Maus-Dragged-Ereignis
*/
private void onPanMouseDragged(MouseEvent event) {
if (panStartX < 0 || scrollPane.isFitToWidth()) {
return;
}
double dx = event.getScreenX() - panStartX;
double dy = event.getScreenY() - panStartY;
Bounds viewport = scrollPane.getViewportBounds();
double contentWidth = viewStack.getWidth();
double contentHeight = viewStack.getHeight();
double viewportWidth = viewport != null ? viewport.getWidth() : 0;
double viewportHeight = viewport != null ? viewport.getHeight() : 0;
double scrollableWidth = contentWidth - viewportWidth;
double scrollableHeight = contentHeight - viewportHeight;
if (scrollableWidth > 0) {
double newHval = panStartHvalue - dx / scrollableWidth;
scrollPane.setHvalue(Math.max(0, Math.min(1, newHval)));
}
if (scrollableHeight > 0) {
double newVval = panStartVvalue - dy / scrollableHeight;
scrollPane.setVvalue(Math.max(0, Math.min(1, newVval)));
}
event.consume();
}
/**
* Beendet die Pan-Geste und stellt den OPEN_HAND-Mauszeiger wieder her.
*
* @param event das Maus-Released-Ereignis
*/
private void onPanMouseReleased(MouseEvent event) {
panStartX = -1;
panStartY = -1;
if (!scrollPane.isFitToWidth()) {
viewStack.setCursor(Cursor.OPEN_HAND);
}
event.consume();
}
// --- Zoom -----------------------------------------------------------------
/**
* Akkumuliert den Scroll-Delta und wendet den Zoom schrittweise an.
* Pro Raste (ca. {@value #ZOOM_NOTCH_THRESHOLD} Einheiten) ändert sich der Zoom
* um {@value #ZOOM_STEP}. Pro ScrollEvent wird maximal eine Zoom-Stufe angewendet.
*
* <p>Der Rohwert von {@code deltaY} wird vor der Akkumulation auf einen
* Notch-Wert ({@value #ZOOM_NOTCH_THRESHOLD}) begrenzt. Plattformspezifische
* Scroll-Multiplikatoren (z. B. Windows-Mausgeschwindigkeit, hohe DPI-Mäuse)
* können sonst Werte wie 120 oder mehr pro Raste liefern, was einen
* Akkumulator-Überlauf in Folge-Events verursacht.
*
* @param deltaY vertikaler Scroll-Delta des {@link ScrollEvent}
*/
private void accumulateAndApplyZoomDelta(double deltaY) {
// Normierung: maximal einen Notch-Wert pro Event akkumulieren, um
// plattformspezifische deltaY-Überhöhungen (z. B. 120 statt 40) abzufangen
double capped = Math.signum(deltaY) * Math.min(Math.abs(deltaY), ZOOM_NOTCH_THRESHOLD);
zoomAccumulator += capped;
if (zoomAccumulator >= ZOOM_NOTCH_THRESHOLD) {
zoomAccumulator -= ZOOM_NOTCH_THRESHOLD;
applyZoom(Math.min(ZOOM_MAX, zoomLevel + ZOOM_STEP));
} else if (zoomAccumulator <= -ZOOM_NOTCH_THRESHOLD) {
zoomAccumulator += ZOOM_NOTCH_THRESHOLD;
applyZoom(Math.max(ZOOM_MIN, zoomLevel - ZOOM_STEP));
}
}
/**
* Setzt den Zoomfaktor und verlässt beim ersten Aufruf den Fit-to-View-Modus.
* <p>
* Beim ersten Aufruf (Wechsel aus dem Fit-Modus) wird {@code zoomLevel} auf
* den aktuellen visuellen Skalierungsfaktor kalibriert: aktuelle visuelle
* Breite der ImageView (mit {@code preserveRatio} bereits aspekt-korrekt
* verkleinert) geteilt durch die natürliche Bildbreite. Damit entspricht
* {@code zoomLevel = 1.0} der pixel-genauen Originalgröße, und der erste
* Zoom-Schritt addiert sich auf den realen Skalierungsfaktor. Ohne diese
* Kalibrierung springt die ImageView abrupt auf {@code Viewport-Breite × 1.10},
* weil im Fit-Modus die {@code fitHeight}-Bindung das Bild aspekt-erhaltend
* deutlich kleiner zwingt als {@code naturalViewportWidth × 1.0} ergibt.
* Da der Caller den Delta-Schritt auf dem alten {@code zoomLevel = 1.0}
* berechnet hat, wird er nach der Kalibrierung auf den neuen, kalibrierten
* {@code zoomLevel} re-appliziert.
* <p>
* Beim Wechsel aus dem Fit-to-View-Modus wird die Ansicht auf die Bildmitte
* zentriert (H/V = 0.5). Bei weiteren Zoom-Schritten bleibt die aktuelle
* Scrollposition erhalten. Ein {@code layout()}-Aufruf vor der
* Positionswiederherstellung stellt sicher, dass die neuen Inhaltsgrenzen
* bereits berechnet sind.
*
* @param newZoom gewünschter Zoomfaktor, wird auf [{@link #ZOOM_MIN}, {@link #ZOOM_MAX}] begrenzt
*/
private void applyZoom(double newZoom) {
double effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom));
boolean wasInFitMode = scrollPane.isFitToWidth();
if (wasInFitMode) {
Image image = imageView.getImage();
if (image == null || image.getWidth() <= 0) {
return; // Kein Bild Zoom-Kalibrierung nicht möglich
}
double naturalImageWidth = image.getWidth();
double currentVisualWidth = imageView.getBoundsInLocal().getWidth();
if (currentVisualWidth <= 0) {
Bounds viewport = scrollPane.getViewportBounds();
currentVisualWidth = viewport != null ? viewport.getWidth() : viewStack.getWidth();
if (currentVisualWidth <= 0) {
return; // Layout noch nicht abgeschlossen
}
}
// Vom Caller intendierten Delta-Schritt vor der Kalibrierung sichern
double requestedDelta = newZoom - zoomLevel;
// zoomLevel auf den aktuellen visuellen Skalierungsfaktor kalibrieren
naturalViewportWidth = naturalImageWidth;
zoomLevel = currentVisualWidth / naturalImageWidth;
// effective neu berechnen, weil zoomLevel sich geändert hat
effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoomLevel + requestedDelta));
scrollPane.setFitToWidth(false);
scrollPane.setFitToHeight(false);
imageView.fitWidthProperty().unbind();
imageView.fitHeightProperty().unbind();
// Mauszeiger signalisiert Pan-Modus
viewStack.setCursor(Cursor.OPEN_HAND);
}
if (effective == zoomLevel) {
return;
}
zoomLevel = effective;
imageView.setFitWidth(naturalViewportWidth * zoomLevel);
imageView.setFitHeight(0);
// Keine manuellen setHvalue/setVvalue-Eingriffe nötig: viewStack hat
// dank des viewportBoundsProperty-Listeners im Konstruktor mindestens
// Viewport-Größe, und Pos.CENTER sorgt für automatische Zentrierung,
// wenn der Inhalt kleiner als der Viewport ist.
}
/**
* Setzt Zoom, Akkumulator und Pan-Zustand zurück und reaktiviert den Fit-to-View-Modus.
* Wird beim Laden einer neuen Datei und beim Leeren der Komponente aufgerufen.
*
* <p>Reihenfolge der Aktionen ist kritisch:
* <ol>
* <li>{@code setFitToWidth(true)} und {@code setFitToHeight(true)} sofort,
* damit der nächste Layout-Pass den {@code viewStack} auf Viewport-Größe
* zurückrechnet.</li>
* <li>Property-Bindungen und H/V-Reset im {@code Platform.runLater}, damit
* sie auf die bereits zurückgerechneten {@code viewStack}-Dimensionen
* wirken und nicht auf die noch zoom-große Breite.</li>
* </ol>
* Ohne diese Reihenfolge würden die Bindungen die imageView kurz an die
* Zoom-Größe koppeln, und ein verbleibender H/V-Wert aus dem Pan-/Zoom-Modus
* (z. B. {@code hvalue=0.0} nach Pan zum linken Rand) würde die PDF wegen
* kleinster Rounding-/Border-Differenzen links/oben bündig statt zentriert
* anzeigen, obwohl der ScrollPane fit-aktiv ist.
*/
private void resetToFitView() {
zoomLevel = 1.0;
zoomAccumulator = 0.0;
naturalViewportWidth = 0.0;
// Pan-Zustand und Mauszeiger zurücksetzen
panStartX = -1;
panStartY = -1;
viewStack.setCursor(null);
if (!scrollPane.isFitToWidth()) {
// 1. ScrollPane in Fit-Modus schalten, damit der nächste Layout-Pass
// den viewStack auf Viewport-Größe zurückrechnet
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
// 2. Bindings erst nach abgeschlossenem Layout-Pass, damit sie auf
// die zurückgerechneten viewStack-Dimensionen wirken
Platform.runLater(() -> {
imageView.fitWidthProperty().bind(viewStack.widthProperty());
imageView.fitHeightProperty().bind(viewStack.heightProperty());
});
}
}
// --- UI-Zustandshelfer --------------------------------------------------- // --- UI-Zustandshelfer ---------------------------------------------------
private void showPlaceholder() { private void showPlaceholder() {
@@ -506,7 +825,7 @@ public final class PdfPreviewPane {
} }
private void updateNavigationButtons() { private void updateNavigationButtons() {
boolean canNavigate = enabled && currentSourceFile != null && totalPages > 0; boolean canNavigate = enabled && currentSourceFile.get() != null && totalPages > 0;
prevButton.setDisable(!canNavigate || currentPage <= 1); prevButton.setDisable(!canNavigate || currentPage <= 1);
nextButton.setDisable(!canNavigate || currentPage >= totalPages); nextButton.setDisable(!canNavigate || currentPage >= totalPages);
} }
@@ -3,6 +3,7 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.Objects; import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus; import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
/** /**
* Zentrale Mapping-Klasse für die visuelle Darstellung von Verarbeitungsstatus in der GUI. * Zentrale Mapping-Klasse für die visuelle Darstellung von Verarbeitungsstatus in der GUI.
@@ -19,6 +20,9 @@ import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
* Alle Methoden sind statisch. * Alle Methoden sind statisch.
*/ */
public final class ProcessingStatusPresentation { public final class ProcessingStatusPresentation {
private static final String STATUS_NOT_NULL = "status darf nicht null sein";
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Icons (Unicode-Zeichen, zuverlässig darstellbar unter Windows 10+) // Icons (Unicode-Zeichen, zuverlässig darstellbar unter Windows 10+)
@@ -165,7 +169,7 @@ public final class ProcessingStatusPresentation {
* @throws NullPointerException wenn {@code status} {@code null} ist * @throws NullPointerException wenn {@code status} {@code null} ist
*/ */
public static String iconFor(DocumentCompletionStatus status) { public static String iconFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein"); Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) { return switch (status) {
case SUCCESS -> ICON_SUCCESS; case SUCCESS -> ICON_SUCCESS;
case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE; case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE;
@@ -186,7 +190,7 @@ public final class ProcessingStatusPresentation {
* @throws NullPointerException wenn {@code status} {@code null} ist * @throws NullPointerException wenn {@code status} {@code null} ist
*/ */
public static String cssColorFor(DocumentCompletionStatus status) { public static String cssColorFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein"); Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) { return switch (status) {
case SUCCESS -> COLOR_SUCCESS; case SUCCESS -> COLOR_SUCCESS;
case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE; case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE;
@@ -204,7 +208,7 @@ public final class ProcessingStatusPresentation {
* @throws NullPointerException wenn {@code status} {@code null} ist * @throws NullPointerException wenn {@code status} {@code null} ist
*/ */
public static String tooltipFor(DocumentCompletionStatus status) { public static String tooltipFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein"); Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) { return switch (status) {
case SUCCESS -> TOOLTIP_SUCCESS; case SUCCESS -> TOOLTIP_SUCCESS;
case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE; case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE;
@@ -223,7 +227,7 @@ public final class ProcessingStatusPresentation {
* @throws NullPointerException wenn {@code status} {@code null} ist * @throws NullPointerException wenn {@code status} {@code null} ist
*/ */
public static String summaryCategoryFor(DocumentCompletionStatus status) { public static String summaryCategoryFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein"); Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) { return switch (status) {
case SUCCESS -> SUMMARY_CATEGORY_SUCCESS; case SUCCESS -> SUMMARY_CATEGORY_SUCCESS;
case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE; case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE;
@@ -242,7 +246,7 @@ public final class ProcessingStatusPresentation {
* @throws NullPointerException wenn {@code status} {@code null} ist * @throws NullPointerException wenn {@code status} {@code null} ist
*/ */
public static StatusVisuals visualsFor(DocumentCompletionStatus status) { public static StatusVisuals visualsFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein"); Objects.requireNonNull(status, STATUS_NOT_NULL);
return new StatusVisuals( return new StatusVisuals(
iconFor(status), iconFor(status),
cssColorFor(status), cssColorFor(status),
@@ -250,6 +254,32 @@ public final class ProcessingStatusPresentation {
summaryCategoryFor(status)); summaryCategoryFor(status));
} }
// -------------------------------------------------------------------------
// Mapping für ProcessingStatus (alle acht Domain-Statuswerte)
// -------------------------------------------------------------------------
/**
* Liefert den deutschsprachigen Anzeigetext mit Icon für den angegebenen
* Domain-Verarbeitungsstatus. Kein Enum-Rohname darf für Endnutzer sichtbar sein.
*
* @param status der Domain-Verarbeitungsstatus; darf nicht {@code null} sein
* @return der Anzeigetext mit vorangestelltem Icon; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String displayTextFor(ProcessingStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> "✓ Erfolgreich";
case FAILED_RETRYABLE -> "↻ Temporärer Fehler";
case FAILED_FINAL -> "× Dauerhaft fehlgeschlagen";
case SKIPPED_ALREADY_PROCESSED -> "≡ Bereits verarbeitet";
case SKIPPED_FINAL_FAILURE -> "⊘ Endgültig übersprungen";
case READY_FOR_AI -> "⟳ Wartet auf Verarbeitung";
case PROPOSAL_READY -> "◇ Vorschlag vorhanden";
case PROCESSING -> "▶ In Bearbeitung";
};
}
/** Nicht instanziierbar reine Utility-Klasse. */ /** Nicht instanziierbar reine Utility-Klasse. */
private ProcessingStatusPresentation() { private ProcessingStatusPresentation() {
throw new UnsupportedOperationException("Nicht instanziierbar"); throw new UnsupportedOperationException("Nicht instanziierbar");
@@ -29,10 +29,10 @@ public record GuiConfigurationEditorState(
* @param values current editable configuration values; must not be {@code null} * @param values current editable configuration values; must not be {@code null}
*/ */
public GuiConfigurationEditorState { public GuiConfigurationEditorState {
loadedFileSnapshot = loadedFileSnapshot == null ? Optional.empty() : loadedFileSnapshot; loadedFileSnapshot = Objects.requireNonNullElse(loadedFileSnapshot, Optional.empty());
baselineValues = Objects.requireNonNull(baselineValues, "baselineValues must not be null"); baselineValues = Objects.requireNonNull(baselineValues, "baselineValues must not be null");
values = Objects.requireNonNull(values, "values must not be null"); values = Objects.requireNonNull(values, "values must not be null");
pendingMigrationMessage = pendingMigrationMessage == null ? Optional.empty() : pendingMigrationMessage; pendingMigrationMessage = Objects.requireNonNullElse(pendingMigrationMessage, Optional.empty());
} }
/** /**
@@ -39,7 +39,7 @@ public record GuiMessageEntry(
Objects.requireNonNull(severity, "severity must not be null"); Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(text, "text must not be null"); Objects.requireNonNull(text, "text must not be null");
Objects.requireNonNull(timestamp, "timestamp must not be null"); Objects.requireNonNull(timestamp, "timestamp must not be null");
source = source == null ? Optional.empty() : source; source = Objects.requireNonNullElse(source, Optional.empty());
} }
/** /**
@@ -15,6 +15,9 @@ import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.AiFailureMessageTranslator;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow; import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
@@ -23,9 +26,13 @@ import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCa
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import javafx.animation.PauseTransition;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.util.Duration;
import javafx.util.StringConverter;
import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
@@ -57,6 +64,7 @@ import javafx.scene.layout.VBox;
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer * Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%), * zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%). * rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
* Das Suchfeld ist mit einem 300-ms-Debounce ausgestattet (Live-Filter).
* *
* <h2>Layout</h2> * <h2>Layout</h2>
* <pre> * <pre>
@@ -79,6 +87,11 @@ import javafx.scene.layout.VBox;
* Verarbeitungslaufs deaktiviert. * Verarbeitungslaufs deaktiviert.
*/ */
public final class GuiHistoryTab { public final class GuiHistoryTab {
private static final String BOLD_STYLE = "-fx-font-weight: bold;";
private static final String NO_ERROR_DETAILS_MSG = "Keine Fehlerdetails gespeichert.";
private static final String NO_CONFIG_LOADED_MSG = "Keine Konfiguration geladen.";
private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class); private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class);
@@ -107,7 +120,7 @@ public final class GuiHistoryTab {
private final Tab tab = new Tab(TAB_TITLE); private final Tab tab = new Tab(TAB_TITLE);
private final TextField searchField = new TextField(); private final TextField searchField = new TextField();
private final ComboBox<String> statusFilterBox = new ComboBox<>(); private final ComboBox<ProcessingStatus> statusFilterBox = new ComboBox<>();
private final Button refreshButton = new Button("Aktualisieren"); private final Button refreshButton = new Button("Aktualisieren");
private final TableView<DocumentHistoryRow> overviewTable = new TableView<>(); private final TableView<DocumentHistoryRow> overviewTable = new TableView<>();
@@ -127,6 +140,7 @@ public final class GuiHistoryTab {
private final TableView<ProcessingAttempt> attemptsTable = new TableView<>(); private final TableView<ProcessingAttempt> attemptsTable = new TableView<>();
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList(); private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
private final TextArea failureArea = new TextArea();
private final TextArea reasoningArea = new TextArea(); private final TextArea reasoningArea = new TextArea();
private final Button resetButton = new Button("Status zurücksetzen"); private final Button resetButton = new Button("Status zurücksetzen");
@@ -135,6 +149,21 @@ public final class GuiHistoryTab {
// ---- Zustand -------------------------------------------------------- // ---- Zustand --------------------------------------------------------
private final ExecutorService workerPool; private final ExecutorService workerPool;
/**
* Debounce-Timer für das Suchfeld: löst {@link #loadOverview()} aus, sobald
* 300 ms nach der letzten Texteingabe vergangen sind.
*/
private final PauseTransition searchDebounce = new PauseTransition(Duration.millis(300));
/**
* Sperre für DB-lesende und DB-schreibende Aktionen während einer
* laufenden Datenbank-Anlage (vgl. Neue Datenbank anlegen"). Wird auf {@code true}
* gesetzt, solange die Anlage einer neuen SQLite-Datenbank läuft, und nach Erfolg
* oder Fehler zuverlässig zurückgesetzt. Während dieser Zeit sind Suche, Filter,
* Aktualisieren, Status-Reset und Löschen deaktiviert.
*/
private boolean dbBusy = false;
/** /**
* Erzeugt den Historien-Tab. * Erzeugt den Historien-Tab.
* *
@@ -189,6 +218,62 @@ public final class GuiHistoryTab {
loadOverview(); loadOverview();
} }
/**
* Wird aufgerufen, wenn ein Verarbeitungslauf beendet wurde, damit Aktionsbuttons
* wieder aktiviert werden können, falls ein Dokument in der Tabelle selektiert ist.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void notifyRunEnded() {
if (!overviewTable.getSelectionModel().getSelectedItems().isEmpty()) {
resetButton.setDisable(false);
deleteButton.setDisable(false);
}
}
/**
* Schaltet die DB-Busy-Sperre des Verlauf-Tabs an oder aus.
* <p>
* Während der Sperre sind Suche, Statusfilter, Aktualisieren, Status-Reset und
* Eintrag-Löschen deaktiviert. Wird typischerweise vom Workspace aufgerufen,
* solange eine neue SQLite-Datenbank angelegt und aktiviert wird.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param busy {@code true} aktiviert die Sperre, {@code false} hebt sie wieder auf
*/
public void setDbBusy(boolean busy) {
this.dbBusy = busy;
searchField.setDisable(busy);
statusFilterBox.setDisable(busy);
refreshButton.setDisable(busy);
if (busy) {
resetButton.setDisable(true);
deleteButton.setDisable(true);
} else if (!overviewTable.getSelectionModel().getSelectedItems().isEmpty()
&& !runningCheck.getAsBoolean()) {
resetButton.setDisable(false);
deleteButton.setDisable(false);
}
}
/**
* Lädt die Übersicht erneut, sofern keine DB-Busy-Sperre aktiv ist.
* <p>
* Wird vom Workspace nach erfolgreichem Datenbank-Wechsel aufgerufen, damit der
* Detailbereich und die Liste die neue (leere) Datenbank wiedergeben.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void reloadAfterDatabaseSwitch() {
// Selektion aufheben, damit der Detailbereich nicht mit Stammdaten
// aus der vorherigen Datenbank weiterzeigt.
overviewTable.getSelectionModel().clearSelection();
overviewItems.clear();
clearDetailPane();
loadOverview();
}
// ========================================================================= // =========================================================================
// UI-Aufbau // UI-Aufbau
// ========================================================================= // =========================================================================
@@ -200,10 +285,14 @@ public final class GuiHistoryTab {
Tooltip.install(searchField, new Tooltip( Tooltip.install(searchField, new Tooltip(
"Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal).")); "Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal)."));
statusFilterBox.getItems().add("Alle Status"); statusFilterBox.setConverter(new StringConverter<>() {
for (ProcessingStatus s : ProcessingStatus.values()) { @Override public String toString(ProcessingStatus s) {
statusFilterBox.getItems().add(s.name()); return s == null ? "Alle Status" : ProcessingStatusPresentation.displayTextFor(s);
} }
@Override public ProcessingStatus fromString(String text) { return null; }
});
statusFilterBox.getItems().add(null); // "Alle Status"
statusFilterBox.getItems().addAll(ProcessingStatus.values());
statusFilterBox.getSelectionModel().selectFirst(); statusFilterBox.getSelectionModel().selectFirst();
Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen.")); Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen."));
@@ -262,12 +351,13 @@ public final class GuiHistoryTab {
private void buildOverviewTable() { private void buildOverviewTable() {
overviewTable.setItems(overviewItems); overviewTable.setItems(overviewItems);
overviewTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); overviewTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT)); overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
// Status-Icon-Spalte // Status-Icon-Spalte
TableColumn<DocumentHistoryRow, String> statusCol = new TableColumn<>("Status"); TableColumn<DocumentHistoryRow, String> statusCol = new TableColumn<>();
statusCol.setGraphic(columnHeader("Status", GuiTooltipTexts.VERLAUF_COL_STATUS));
statusCol.setCellValueFactory(cell -> statusCol.setCellValueFactory(cell ->
new SimpleStringProperty(statusIcon(cell.getValue().overallStatus()))); new SimpleStringProperty(statusIcon(cell.getValue().overallStatus())));
statusCol.setCellFactory(col -> new TableCell<>() { statusCol.setCellFactory(col -> new TableCell<>() {
@@ -289,27 +379,31 @@ public final class GuiHistoryTab {
statusCol.setMaxWidth(70); statusCol.setMaxWidth(70);
// Quelldateiname // Quelldateiname
TableColumn<DocumentHistoryRow, String> sourceCol = new TableColumn<>("Quelldatei"); TableColumn<DocumentHistoryRow, String> sourceCol = new TableColumn<>();
sourceCol.setGraphic(columnHeader("Quelldatei", GuiTooltipTexts.VERLAUF_COL_QUELLDATEI));
sourceCol.setCellValueFactory(cell -> sourceCol.setCellValueFactory(cell ->
new SimpleStringProperty(cell.getValue().sourceFileName())); new SimpleStringProperty(cell.getValue().sourceFileName()));
sourceCol.setCellFactory(col -> ellipsisCell()); sourceCol.setCellFactory(col -> ellipsisCell());
// Zieldateiname // Zieldateiname
TableColumn<DocumentHistoryRow, String> targetCol = new TableColumn<>("Zieldatei"); TableColumn<DocumentHistoryRow, String> targetCol = new TableColumn<>();
targetCol.setGraphic(columnHeader("Zieldatei", GuiTooltipTexts.VERLAUF_COL_ZIELDATEI));
targetCol.setCellValueFactory(cell -> targetCol.setCellValueFactory(cell ->
new SimpleStringProperty( new SimpleStringProperty(
cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : "")); cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : ""));
targetCol.setCellFactory(col -> ellipsisCell()); targetCol.setCellFactory(col -> ellipsisCell());
// Letzter Versuch // Letzter Versuch
TableColumn<DocumentHistoryRow, String> updatedCol = new TableColumn<>("Letzter Versuch"); TableColumn<DocumentHistoryRow, String> updatedCol = new TableColumn<>();
updatedCol.setGraphic(columnHeader("Letzter Versuch", GuiTooltipTexts.VERLAUF_COL_LETZTER_VERSUCH));
updatedCol.setCellValueFactory(cell -> updatedCol.setCellValueFactory(cell ->
new SimpleStringProperty(formatInstant(cell.getValue().updatedAt()))); new SimpleStringProperty(formatInstant(cell.getValue().updatedAt())));
updatedCol.setPrefWidth(140); updatedCol.setPrefWidth(140);
updatedCol.setMaxWidth(160); updatedCol.setMaxWidth(160);
// Anzahl Versuche // Anzahl Versuche
TableColumn<DocumentHistoryRow, String> countCol = new TableColumn<>("Versuche"); TableColumn<DocumentHistoryRow, String> countCol = new TableColumn<>();
countCol.setGraphic(columnHeader("Versuche", GuiTooltipTexts.VERLAUF_COL_VERSUCHE));
countCol.setCellValueFactory(cell -> countCol.setCellValueFactory(cell ->
new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount()))); new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount())));
countCol.setPrefWidth(70); countCol.setPrefWidth(70);
@@ -332,24 +426,36 @@ public final class GuiHistoryTab {
addDetailRow(5, "Aktualisiert:", detailUpdatedLabel); addDetailRow(5, "Aktualisiert:", detailUpdatedLabel);
Label detailTitle = new Label("Dokument-Details"); Label detailTitle = new Label("Dokument-Details");
detailTitle.setStyle("-fx-font-weight: bold;"); detailTitle.setStyle(BOLD_STYLE);
// Versuche-Tabelle // Versuche-Tabelle
buildAttemptsTable(); buildAttemptsTable();
Label attemptsTitle = new Label("Verarbeitungsversuche"); Label attemptsTitle = new Label("Verarbeitungsversuche");
attemptsTitle.setStyle("-fx-font-weight: bold;"); attemptsTitle.setStyle(BOLD_STYLE);
// Fehlerursache (aus letztem Fehler-Versuch)
failureArea.setEditable(false);
failureArea.setWrapText(true);
failureArea.setPrefRowCount(3);
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
Label failureTitle = new Label("Fehlerursache (letzter Fehler-Versuch)");
failureTitle.setStyle(BOLD_STYLE);
failureArea.setTooltip(new Tooltip(GuiTooltipTexts.VERLAUF_FAILURE_AREA));
// KI-Begründung // KI-Begründung
reasoningArea.setEditable(false); reasoningArea.setEditable(false);
reasoningArea.setWrapText(true); reasoningArea.setWrapText(true);
reasoningArea.setPrefRowCount(4); reasoningArea.setPrefRowCount(4);
reasoningArea.setText(DETAIL_PLACEHOLDER); reasoningArea.setText(DETAIL_PLACEHOLDER);
reasoningArea.setTooltip(new Tooltip(GuiTooltipTexts.VERLAUF_REASONING_AREA));
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)"); Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
reasoningTitle.setStyle("-fx-font-weight: bold;"); reasoningTitle.setStyle(BOLD_STYLE);
VBox rightPane = new VBox(8, VBox rightPane = new VBox(8,
detailTitle, detailGrid, detailTitle, detailGrid,
attemptsTitle, attemptsTable, attemptsTitle, attemptsTable,
failureTitle, failureArea,
reasoningTitle, reasoningArea); reasoningTitle, reasoningArea);
rightPane.setPadding(new Insets(4, 8, 4, 4)); rightPane.setPadding(new Insets(4, 8, 4, 4));
VBox.setVgrow(attemptsTable, Priority.ALWAYS); VBox.setVgrow(attemptsTable, Priority.ALWAYS);
@@ -370,37 +476,43 @@ public final class GuiHistoryTab {
attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
attemptsTable.setPrefHeight(150); attemptsTable.setPrefHeight(150);
TableColumn<ProcessingAttempt, String> numCol = new TableColumn<>("#"); TableColumn<ProcessingAttempt, String> numCol = new TableColumn<>();
numCol.setGraphic(columnHeader("#", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_NR));
numCol.setCellValueFactory(c -> numCol.setCellValueFactory(c ->
new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber()))); new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber())));
numCol.setPrefWidth(40); numCol.setPrefWidth(40);
numCol.setMaxWidth(50); numCol.setMaxWidth(50);
TableColumn<ProcessingAttempt, String> dateCol = new TableColumn<>("Datum"); TableColumn<ProcessingAttempt, String> dateCol = new TableColumn<>();
dateCol.setGraphic(columnHeader("Datum", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_DATUM));
dateCol.setCellValueFactory(c -> dateCol.setCellValueFactory(c ->
new SimpleStringProperty(formatInstant(c.getValue().endedAt()))); new SimpleStringProperty(formatInstant(c.getValue().endedAt())));
dateCol.setPrefWidth(130); dateCol.setPrefWidth(130);
dateCol.setMaxWidth(150); dateCol.setMaxWidth(150);
TableColumn<ProcessingAttempt, String> statusCol = new TableColumn<>("Status"); TableColumn<ProcessingAttempt, String> statusCol = new TableColumn<>();
statusCol.setGraphic(columnHeader("Status", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_STATUS));
statusCol.setCellValueFactory(c -> statusCol.setCellValueFactory(c ->
new SimpleStringProperty( new SimpleStringProperty(
statusIcon(c.getValue().status()) + " " + c.getValue().status().name())); ProcessingStatusPresentation.displayTextFor(c.getValue().status())));
statusCol.setPrefWidth(140); statusCol.setPrefWidth(160);
TableColumn<ProcessingAttempt, String> providerCol = new TableColumn<>("Provider"); TableColumn<ProcessingAttempt, String> providerCol = new TableColumn<>();
providerCol.setGraphic(columnHeader("Provider", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_PROVIDER));
providerCol.setCellValueFactory(c -> providerCol.setCellValueFactory(c ->
new SimpleStringProperty( new SimpleStringProperty(
c.getValue().aiProvider() != null ? c.getValue().aiProvider() : "")); c.getValue().aiProvider() != null ? c.getValue().aiProvider() : ""));
providerCol.setPrefWidth(90); providerCol.setPrefWidth(90);
TableColumn<ProcessingAttempt, String> modelCol = new TableColumn<>("Modell"); TableColumn<ProcessingAttempt, String> modelCol = new TableColumn<>();
modelCol.setGraphic(columnHeader("Modell", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_MODELL));
modelCol.setCellValueFactory(c -> modelCol.setCellValueFactory(c ->
new SimpleStringProperty( new SimpleStringProperty(
c.getValue().modelName() != null ? c.getValue().modelName() : "")); c.getValue().modelName() != null ? c.getValue().modelName() : ""));
modelCol.setCellFactory(col -> ellipsisCell()); modelCol.setCellFactory(col -> ellipsisCell());
TableColumn<ProcessingAttempt, String> fileNameCol = new TableColumn<>("Vorgeschlagener Name"); TableColumn<ProcessingAttempt, String> fileNameCol = new TableColumn<>();
fileNameCol.setGraphic(columnHeader("Vorgeschlagener Name", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_VORGESCHLAGENER_NAME));
fileNameCol.setCellValueFactory(c -> fileNameCol.setCellValueFactory(c ->
new SimpleStringProperty( new SimpleStringProperty(
c.getValue().finalTargetFileName() != null c.getValue().finalTargetFileName() != null
@@ -417,23 +529,34 @@ public final class GuiHistoryTab {
private void wireEvents() { private void wireEvents() {
refreshButton.setOnAction(e -> loadOverview()); refreshButton.setOnAction(e -> loadOverview());
// Debounce-artige Aktualisierung bei Texteingabe: direkte Suche bei Enter, // Live-Filter: 300-ms-Debounce bei jeder Texteingabe
// sonst über Fokus-Verlust oder expliziten Aktualisieren-Button searchDebounce.setOnFinished(e -> loadOverview());
searchField.setOnAction(e -> loadOverview()); searchField.textProperty().addListener((obs, old, val) -> searchDebounce.playFromStart());
// Enter-Taste: sofort suchen, Debounce-Timer stoppen
searchField.setOnAction(e -> { searchDebounce.stop(); loadOverview(); });
statusFilterBox.setOnAction(e -> loadOverview()); statusFilterBox.setOnAction(e -> loadOverview());
// Detailbereich bei Zeilenselektion // Detailbereich und Buttons bei Selektionsänderung aktualisieren
overviewTable.getSelectionModel().selectedItemProperty().addListener( overviewTable.getSelectionModel().getSelectedItems().addListener(
(obs, old, selected) -> { (ListChangeListener<DocumentHistoryRow>) change -> {
if (selected == null) { List<DocumentHistoryRow> sel =
List.copyOf(overviewTable.getSelectionModel().getSelectedItems());
boolean running = runningCheck.getAsBoolean();
if (sel.isEmpty()) {
clearDetailPane(); clearDetailPane();
resetButton.setDisable(true); resetButton.setDisable(true);
deleteButton.setDisable(true); deleteButton.setDisable(true);
} else if (sel.size() == 1) {
resetButton.setDisable(running);
deleteButton.setDisable(running);
loadDetails(sel.get(0).fingerprint());
} else { } else {
resetButton.setDisable(runningCheck.getAsBoolean()); // Mehrfachauswahl: Detail-Bereich löschen, Buttons aktivieren
deleteButton.setDisable(runningCheck.getAsBoolean()); clearDetailPane();
loadDetails(selected.fingerprint()); resetButton.setDisable(running);
deleteButton.setDisable(running);
statusBarLabel.setText(sel.size() + " Einträge ausgewählt.");
} }
}); });
@@ -461,14 +584,13 @@ public final class GuiHistoryTab {
Path configPath = configPathSupplier.get(); Path configPath = configPathSupplier.get();
if (configPath == null) { if (configPath == null) {
statusBarLabel.setText("Keine Konfiguration geladen bitte zuerst eine Konfigurationsdatei öffnen."); statusBarLabel.setText("Keine Konfiguration geladen bitte zuerst eine Konfigurationsdatei öffnen.");
overviewTable.setPlaceholder(new Label("Keine Konfiguration geladen.")); overviewTable.setPlaceholder(new Label(NO_CONFIG_LOADED_MSG));
return; return;
} }
String searchText = searchField.getText(); String searchText = searchField.getText();
String selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem(); ProcessingStatus selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem();
String statusFilter = (selectedStatus == null || "Alle Status".equals(selectedStatus)) String statusFilter = selectedStatus == null ? null : selectedStatus.name();
? null : selectedStatus;
HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT); HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT);
@@ -543,97 +665,141 @@ public final class GuiHistoryTab {
return; return;
} }
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem(); List<DocumentHistoryRow> selectedItems =
if (selected == null) return; List.copyOf(overviewTable.getSelectionModel().getSelectedItems());
if (selectedItems.isEmpty()) return;
Path configPath = configPathSupplier.get();
if (configPath == null) {
showInfo(NO_CONFIG_LOADED_MSG);
return;
}
long successCount = selectedItems.stream()
.filter(r -> r.overallStatus() == ProcessingStatus.SUCCESS)
.count();
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION); Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
confirm.setTitle("Status zurücksetzen"); confirm.setTitle("Status zurücksetzen");
confirm.setHeaderText("Status zurücksetzen?"); confirm.setHeaderText("Status zurücksetzen?");
confirm.setContentText( confirm.setContentText(buildResetConfirmationText(selectedItems, successCount));
"Setzt den Status des Dokuments auf READY_FOR_AI zurück.\n"
+ "Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n"
+ "Die Versuchshistorie bleibt vollständig erhalten.\n\n"
+ "Das Dokument wird beim nächsten Verarbeitungslauf erneut verarbeitet.\n\n"
+ "Quelldatei: " + selected.sourceFileName());
Optional<ButtonType> choice = confirm.showAndWait(); Optional<ButtonType> choice = confirm.showAndWait();
if (choice.isEmpty() || choice.get() != ButtonType.OK) return; if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
DocumentFingerprint fp = selected.fingerprint();
Path configPath = configPathSupplier.get();
if (configPath == null) {
showInfo("Keine Konfiguration geladen.");
return;
}
resetButton.setDisable(true); resetButton.setDisable(true);
deleteButton.setDisable(true); deleteButton.setDisable(true);
statusBarLabel.setText("Status wird zurückgesetzt …"); statusBarLabel.setText("Status wird zurückgesetzt …");
workerPool.submit(() -> { workerPool.submit(() -> {
try { int okCount = 0;
resetPort.resetStatus(configPath, fp); int errCount = 0;
LOG.info("Status-Reset durchgeführt für Fingerprint: {}", fp.sha256Hex()); for (DocumentHistoryRow row : selectedItems) {
Platform.runLater(() -> { try {
statusBarLabel.setText("Status erfolgreich zurückgesetzt."); resetPort.resetStatus(configPath, row.fingerprint());
loadOverview(); LOG.info("Status-Reset durchgeführt für Fingerprint: {}", row.fingerprint().sha256Hex());
}); okCount++;
} catch (Exception ex) { } catch (Exception ex) {
LOG.error("Status-Reset fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex); LOG.error("Status-Reset fehlgeschlagen für {}: {}",
Platform.runLater(() -> { row.fingerprint().sha256Hex(), ex.getMessage(), ex);
statusBarLabel.setText("Fehler beim Status-Reset: " + ex.getMessage()); errCount++;
resetButton.setDisable(false); }
deleteButton.setDisable(false);
});
} }
final int ok = okCount, err = errCount;
Platform.runLater(() -> {
if (err == 0) {
statusBarLabel.setText("Status erfolgreich zurückgesetzt: " + ok + " Eintrag/Einträge.");
} else {
statusBarLabel.setText("Status zurückgesetzt: " + ok + " OK, " + err + " Fehler.");
}
loadOverview();
});
}); });
} }
private static String buildResetConfirmationText(List<DocumentHistoryRow> selectedItems, long successCount) {
StringBuilder sb = new StringBuilder();
sb.append("Setzt den Status auf READY_FOR_AI zurück.\n");
sb.append("Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n");
sb.append("Die Versuchshistorie bleibt vollständig erhalten.\n\n");
if (selectedItems.size() == 1) {
sb.append("Quelldatei: ").append(selectedItems.get(0).sourceFileName());
} else {
sb.append(selectedItems.size()).append(" Einträge werden zurückgesetzt.");
}
if (successCount > 0) {
sb.append("\n\nHinweis: ").append(successCount)
.append(" der ausgewählten Einträge ")
.append(successCount == 1 ? "hat" : "haben")
.append(" Status \"Erfolgreich\". ")
.append(successCount == 1 ? "Dieser Eintrag wird" : "Diese Einträge werden")
.append(" erneut verarbeitet.");
}
return sb.toString();
}
private void handleDeleteAction() { private void handleDeleteAction() {
if (runningCheck.getAsBoolean()) { if (runningCheck.getAsBoolean()) {
showInfo(LAUF_AKTIV_HINWEIS); showInfo(LAUF_AKTIV_HINWEIS);
return; return;
} }
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem(); List<DocumentHistoryRow> selectedItems =
if (selected == null) return; List.copyOf(overviewTable.getSelectionModel().getSelectedItems());
if (selectedItems.isEmpty()) return;
Path configPath = configPathSupplier.get();
if (configPath == null) {
showInfo(NO_CONFIG_LOADED_MSG);
return;
}
String contentText;
if (selectedItems.size() == 1) {
contentText = "Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n"
+ "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
+ "Quelldatei: " + selectedItems.get(0).sourceFileName();
} else {
contentText = selectedItems.size() + " Einträge werden mit allen Versuchen unwiderruflich gelöscht.\n"
+ "Diese Aktion kann nicht rückgängig gemacht werden.";
}
Alert confirm = new Alert(Alert.AlertType.WARNING); Alert confirm = new Alert(Alert.AlertType.WARNING);
confirm.setTitle("Eintrag löschen"); confirm.setTitle("Eintrag löschen");
confirm.setHeaderText("Eintrag vollständig löschen?"); confirm.setHeaderText(selectedItems.size() == 1 ? "Eintrag vollständig löschen?"
confirm.setContentText( : selectedItems.size() + " Einträge vollständig löschen?");
"Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n" confirm.setContentText(contentText);
+ "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
+ "Quelldatei: " + selected.sourceFileName());
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL); confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
Optional<ButtonType> choice = confirm.showAndWait(); Optional<ButtonType> choice = confirm.showAndWait();
if (choice.isEmpty() || choice.get() != ButtonType.OK) return; if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
DocumentFingerprint fp = selected.fingerprint();
Path configPath = configPathSupplier.get();
if (configPath == null) {
showInfo("Keine Konfiguration geladen.");
return;
}
resetButton.setDisable(true); resetButton.setDisable(true);
deleteButton.setDisable(true); deleteButton.setDisable(true);
statusBarLabel.setText("Eintrag wird gelöscht …"); statusBarLabel.setText("Einträge werden gelöscht …");
workerPool.submit(() -> { workerPool.submit(() -> {
try { int okCount = 0;
deletePort.deleteHistory(configPath, fp); int errCount = 0;
LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", fp.sha256Hex()); for (DocumentHistoryRow row : selectedItems) {
Platform.runLater(() -> { try {
statusBarLabel.setText("Eintrag erfolgreich gelöscht."); deletePort.deleteHistory(configPath, row.fingerprint());
clearDetailPane(); LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", row.fingerprint().sha256Hex());
loadOverview(); okCount++;
}); } catch (Exception ex) {
} catch (Exception ex) { LOG.error("Löschen fehlgeschlagen für {}: {}",
LOG.error("Löschen fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex); row.fingerprint().sha256Hex(), ex.getMessage(), ex);
Platform.runLater(() -> { errCount++;
statusBarLabel.setText("Fehler beim Löschen: " + ex.getMessage()); }
resetButton.setDisable(false);
deleteButton.setDisable(false);
});
} }
final int ok = okCount, err = errCount;
Platform.runLater(() -> {
if (err == 0) {
statusBarLabel.setText("Gelöscht: " + ok + " Eintrag/Einträge.");
} else {
statusBarLabel.setText("Gelöscht: " + ok + " OK, " + err + " Fehler.");
}
clearDetailPane();
loadOverview();
});
}); });
} }
@@ -649,8 +815,7 @@ public final class GuiHistoryTab {
detailSourceFileLabel.setText(record.lastKnownSourceFileName()); detailSourceFileLabel.setText(record.lastKnownSourceFileName());
detailSourcePathLabel.setText(record.lastKnownSourceLocator().value()); detailSourcePathLabel.setText(record.lastKnownSourceLocator().value());
detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value())); detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value()));
String icon = statusIcon(record.overallStatus()); detailStatusLabel.setText(ProcessingStatusPresentation.displayTextFor(record.overallStatus()));
detailStatusLabel.setText(icon + " " + record.overallStatus().name());
detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";"); detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";");
detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus()))); detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus())));
detailCreatedLabel.setText(formatInstant(record.createdAt())); detailCreatedLabel.setText(formatInstant(record.createdAt()));
@@ -658,33 +823,78 @@ public final class GuiHistoryTab {
attemptsItems.setAll(result.attempts()); attemptsItems.setAll(result.attempts());
// Neuesten Versuch selektieren und Begründung anzeigen // Fehlerursache aus letztem Fehler-Versuch anzeigen
if (!result.attempts().isEmpty()) { showLastFailureMessage(result.attempts(), record.overallStatus());
ProcessingAttempt last = result.attempts().get(result.attempts().size() - 1);
selectLatestAttemptAndShowReasoning(result.attempts());
attemptsTable.getSelectionModel().selectedItemProperty().addListener(
(obs, old, attempt) -> onAttemptSelected(attempt));
}
private void selectLatestAttemptAndShowReasoning(java.util.List<ProcessingAttempt> attempts) {
if (!attempts.isEmpty()) {
ProcessingAttempt last = attempts.get(attempts.size() - 1);
attemptsTable.getSelectionModel().select(last); attemptsTable.getSelectionModel().select(last);
showReasoning(last); showReasoning(last);
} else { } else {
reasoningArea.setText(NO_REASONING_TEXT); reasoningArea.setText("");
reasoningArea.setPromptText(NO_REASONING_TEXT);
}
}
private void onAttemptSelected(ProcessingAttempt attempt) {
if (attempt != null) {
showReasoning(attempt);
}
}
/**
* Zeigt die Fehlerursache des letzten Fehlschlags im Fehlerursache-Bereich an.
* Relevant bei Status FAILED_FINAL, FAILED_RETRYABLE und SKIPPED_FINAL_FAILURE.
* Bei fehlendem Eintrag oder leerem Feld wird ein Platzhalter-Text gesetzt.
*/
private void showLastFailureMessage(List<ProcessingAttempt> attempts, ProcessingStatus overallStatus) {
boolean failureRelevant = overallStatus == ProcessingStatus.FAILED_FINAL
|| overallStatus == ProcessingStatus.FAILED_RETRYABLE
|| overallStatus == ProcessingStatus.SKIPPED_FINAL_FAILURE;
if (!failureRelevant || attempts.isEmpty()) {
failureArea.setText("");
failureArea.setPromptText("Keine Fehlerdetails für diesen Status.");
return;
} }
// KI-Begründung bei Versuchs-Selektion aktualisieren // Letzten Versuch mit nicht-leerem failure_message suchen (absteigend nach attempt_number)
attemptsTable.getSelectionModel().selectedItemProperty().addListener( String failureMessage = null;
(obs, old, attempt) -> { for (int i = attempts.size() - 1; i >= 0; i--) {
if (attempt != null) { String msg = attempts.get(i).failureMessage();
showReasoning(attempt); if (msg != null && !msg.isBlank()) {
} failureMessage = msg;
}); break;
}
}
failureArea.setText(failureMessage != null
? AiFailureMessageTranslator.translate(failureMessage) : "");
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
} }
private void showReasoning(ProcessingAttempt attempt) { private void showReasoning(ProcessingAttempt attempt) {
String reasoning = attempt.aiReasoning(); String reasoning = attempt.aiReasoning();
reasoningArea.setText(reasoning != null && !reasoning.isBlank() if (reasoning != null && !reasoning.isBlank()) {
? reasoning : NO_REASONING_TEXT); reasoningArea.setText(reasoning);
reasoningArea.setPromptText("");
} else {
reasoningArea.setText("");
reasoningArea.setPromptText(NO_REASONING_TEXT);
}
} }
private void clearDetailPane() { private void clearDetailPane() {
clearDetailFields(); clearDetailFields();
attemptsItems.clear(); attemptsItems.clear();
failureArea.setText("");
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
reasoningArea.setText(DETAIL_PLACEHOLDER); reasoningArea.setText(DETAIL_PLACEHOLDER);
} }
@@ -707,7 +917,7 @@ public final class GuiHistoryTab {
private void addDetailRow(int row, String labelText, Label valueLabel) { private void addDetailRow(int row, String labelText, Label valueLabel) {
Label label = new Label(labelText); Label label = new Label(labelText);
label.setStyle("-fx-font-weight: bold;"); label.setStyle(BOLD_STYLE);
valueLabel.setMaxWidth(Double.MAX_VALUE); valueLabel.setMaxWidth(Double.MAX_VALUE);
GridPane.setHgrow(valueLabel, Priority.ALWAYS); GridPane.setHgrow(valueLabel, Priority.ALWAYS);
detailGrid.add(label, 0, row); detailGrid.add(label, 0, row);
@@ -762,6 +972,21 @@ public final class GuiHistoryTab {
}; };
} }
/**
* Erzeugt ein Label für den Spaltenkopf einer TableColumn mit Tooltip.
* Wird anstelle von {@code column.setText()} verwendet, da TableColumn
* kein direktes {@code setTooltip()} unterstützt.
*
* @param title sichtbarer Spaltentext
* @param tooltip Tooltip-Text
* @return ein Label mit gesetztem Tooltip
*/
private static Label columnHeader(String title, String tooltip) {
Label label = new Label(title);
label.setTooltip(new Tooltip(tooltip));
return label;
}
private static <T> TableCell<T, String> ellipsisCell() { private static <T> TableCell<T, String> ellipsisCell() {
return new TableCell<>() { return new TableCell<>() {
@Override @Override
@@ -244,16 +244,18 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible"); "The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(), assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible"); "The 'Speichern unter' button must be visible");
assertEquals(4, workspace.tabPane().getTabs().size(), assertEquals(5, workspace.tabPane().getTabs().size(),
"Configuration tab, processing-run tab, history tab and prompt editor tab must all be present"); "Configuration tab, processing-run tab, scheduler tab, history tab and prompt editor tab must all be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(), assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The first tab must use the configuration label"); "The first tab must use the configuration label");
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(), assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
"The second tab must host the processing-run view"); "The second tab must host the processing-run view");
assertEquals("Verlauf", workspace.tabPane().getTabs().get(2).getText(), assertEquals("Scheduler", workspace.tabPane().getTabs().get(2).getText(),
"The third tab must host the history view"); "The third tab must host the scheduler control");
assertEquals("Prompt", workspace.tabPane().getTabs().get(3).getText(), assertEquals("Verlauf", workspace.tabPane().getTabs().get(3).getText(),
"The fourth tab must host the prompt editor"); "The fourth tab must host the history view");
assertEquals("Prompt", workspace.tabPane().getTabs().get(4).getText(),
"The fifth tab must host the prompt editor");
assertEquals( assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen", "Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()), String.join(",", workspace.sectionTitles()),
@@ -23,10 +23,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort; import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort; import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab; import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.scene.control.Tab; import javafx.scene.control.Tab;
@@ -1,11 +1,9 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui; package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Properties; import java.util.Properties;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@@ -119,9 +119,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
void startReset_invokesResetPortAndDispatchesResult() { void startReset_invokesResetPortAndDispatchesResult() {
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>(); AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() { GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { } @Override public void onRunStarted(RunId runId, int totalCandidates) {
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { } // intentionally empty
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { } }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
@Override public void onResetCompleted(ResetDocumentStatusResult result) { @Override public void onResetCompleted(ResetDocumentStatusResult result) {
captured.set(result); captured.set(result);
} }
@@ -170,9 +176,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
void startReset_portThrowsException_mapsToAllFailures() { void startReset_portThrowsException_mapsToAllFailures() {
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>(); AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() { GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { } @Override public void onRunStarted(RunId runId, int totalCandidates) {
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { } // intentionally empty
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { } }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
@Override public void onResetCompleted(ResetDocumentStatusResult result) { @Override public void onResetCompleted(ResetDocumentStatusResult result) {
captured.set(result); captured.set(result);
} }
@@ -198,9 +210,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
void listenerDefaultOnResetCompleted_doesNotThrow() { void listenerDefaultOnResetCompleted_doesNotThrow() {
// Verify the default implementation is safe to call. // Verify the default implementation is safe to call.
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() { GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { } @Override public void onRunStarted(RunId runId, int totalCandidates) {
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { } // intentionally empty
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { } }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
}; };
listener.onResetCompleted(new ResetDocumentStatusResult(0, Set.of(), Map.of())); listener.onResetCompleted(new ResetDocumentStatusResult(0, Set.of(), Map.of()));
} }
@@ -223,9 +241,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
private static GuiBatchRunCoordinator.Listener noOpListener() { private static GuiBatchRunCoordinator.Listener noOpListener() {
return new GuiBatchRunCoordinator.Listener() { return new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { } @Override public void onRunStarted(RunId runId, int totalCandidates) {
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { } // intentionally empty
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { } }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
}; };
} }
@@ -247,8 +247,12 @@ class GuiBatchRunCoordinatorTest {
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator( GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
launcher, syncThreadFactory(), syncDispatcher(), launcher, syncThreadFactory(), syncDispatcher(),
new GuiBatchRunCoordinator.Listener() { new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { } @Override public void onRunStarted(RunId runId, int totalCandidates) {
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { } // intentionally empty
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { @Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
captured.set(outcome); captured.set(outcome);
} }
@@ -270,8 +274,12 @@ class GuiBatchRunCoordinatorTest {
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator( GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
launcher, syncThreadFactory(), syncDispatcher(), launcher, syncThreadFactory(), syncDispatcher(),
new GuiBatchRunCoordinator.Listener() { new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { } @Override public void onRunStarted(RunId runId, int totalCandidates) {
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { } // intentionally empty
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { @Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
captured.set(outcome); captured.set(outcome);
} }
@@ -322,9 +330,15 @@ class GuiBatchRunCoordinatorTest {
private static GuiBatchRunCoordinator.Listener noOpListener() { private static GuiBatchRunCoordinator.Listener noOpListener() {
return new GuiBatchRunCoordinator.Listener() { return new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { } @Override public void onRunStarted(RunId runId, int totalCandidates) {
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { } // intentionally empty
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { } }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
// intentionally empty
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
// intentionally empty
}
}; };
} }
@@ -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());
}
}
}
@@ -6,7 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
+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);
}
}
@@ -95,6 +95,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
* </ul> * </ul>
*/ */
public class OpenAiHttpAdapter implements AiInvocationPort { public class OpenAiHttpAdapter implements AiInvocationPort {
private static final String NO_CHOICE_CONTENT_SENTINEL = "NO_CHOICE_CONTENT";
private static final String JSON_KEY_CONTENT = "content";
private static final Logger LOG = LogManager.getLogger(OpenAiHttpAdapter.class); private static final Logger LOG = LogManager.getLogger(OpenAiHttpAdapter.class);
@@ -248,20 +252,20 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
JSONArray choices = json.optJSONArray("choices"); JSONArray choices = json.optJSONArray("choices");
if (choices == null || choices.isEmpty()) { if (choices == null || choices.isEmpty()) {
LOG.warn("OpenAI response contained no choices"); LOG.warn("OpenAI response contained no choices");
return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT", return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
"OpenAI response contained no choices"); "OpenAI response contained no choices");
} }
JSONObject firstChoice = choices.getJSONObject(0); JSONObject firstChoice = choices.getJSONObject(0);
JSONObject message = firstChoice.optJSONObject("message"); JSONObject message = firstChoice.optJSONObject("message");
if (message == null) { if (message == null) {
LOG.warn("OpenAI response choice contained no message"); LOG.warn("OpenAI response choice contained no message");
return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT", return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
"OpenAI response choice contained no message"); "OpenAI response choice contained no message");
} }
String content = message.optString("content", null); String content = message.optString(JSON_KEY_CONTENT, null);
if (content == null || content.isBlank()) { if (content == null || content.isBlank()) {
LOG.warn("OpenAI response message.content is absent or blank"); LOG.warn("OpenAI response message.content is absent or blank");
return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT", return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
"OpenAI response message.content is absent or blank"); "OpenAI response message.content is absent or blank");
} }
return new AiInvocationSuccess(request, new AiRawResponse(content)); return new AiInvocationSuccess(request, new AiRawResponse(content));
@@ -347,11 +351,11 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
JSONObject systemMessage = new JSONObject(); JSONObject systemMessage = new JSONObject();
systemMessage.put("role", "system"); systemMessage.put("role", "system");
systemMessage.put("content", request.promptContent()); systemMessage.put(JSON_KEY_CONTENT, request.promptContent());
JSONObject userMessage = new JSONObject(); JSONObject userMessage = new JSONObject();
userMessage.put("role", "user"); userMessage.put("role", "user");
userMessage.put("content", request.documentText()); userMessage.put(JSON_KEY_CONTENT, request.documentText());
body.put("messages", new org.json.JSONArray() body.put("messages", new org.json.JSONArray()
.put(systemMessage) .put(systemMessage)
@@ -4,22 +4,26 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
/** /**
* File-based implementation of {@link RunLockPort} that uses a lock file to prevent concurrent runs. * Dateibasierte Implementierung von {@link RunLockPort}.
* <p> * <p>
* Creates an exclusive lock file on acquire and deletes it on release. * Verwendet eine Lock-Datei, um parallele Läufe zu verhindern.
* If the lock file already exists, {@link #acquire()} throws {@link RunLockUnavailableException} * Beim Erwerb wird die Lock-Datei angelegt; bei der Freigabe wird sie gelöscht.
* to signal that another instance is already running. * Existiert die Datei bereits, ist der Lock belegt.
* <p> * <p>
* The lock file contains the PID of the acquiring process. Release is best-effort: a failure * Die Lock-Datei enthält die PID des erwerbenden Prozesses.
* to delete the lock file is logged as a warning but does not throw. * Die Freigabe ist best-effort: Ein Fehler beim Löschen wird als Warnung
* geloggt, wirft aber keine Ausnahme.
*/ */
public class FilesystemRunLockPortAdapter implements RunLockPort { public class FilesystemRunLockPortAdapter implements RunLockPort {
@@ -28,27 +32,31 @@ public class FilesystemRunLockPortAdapter implements RunLockPort {
private final Path lockFile; private final Path lockFile;
/** /**
* Creates a new FilesystemRunLockPortAdapter for the given lock file path. * Erstellt einen neuen {@code FilesystemRunLockPortAdapter} für den
* angegebenen Lock-Datei-Pfad.
* *
* @param lockFile path of the lock file to create on acquire and delete on release * @param lockFile Pfad der Lock-Datei, die beim Erwerb angelegt und
* bei der Freigabe gelöscht wird
*/ */
public FilesystemRunLockPortAdapter(Path lockFile) { public FilesystemRunLockPortAdapter(Path lockFile) {
this.lockFile = lockFile; this.lockFile = lockFile;
} }
/** /**
* Acquires the run lock by creating the lock file. * Erwirbt den Run-Lock durch Anlegen der Lock-Datei (blockierend).
* <p> * <p>
* If the lock file already exists, throws {@link RunLockUnavailableException}. * Existiert die Lock-Datei bereits, wird eine
* If the parent directory does not exist, it is created before attempting file creation. * {@link RunLockUnavailableException} geworfen. Das übergeordnete
* Verzeichnis wird bei Bedarf angelegt.
* *
* @throws RunLockUnavailableException if the lock file already exists or cannot be created * @throws RunLockUnavailableException wenn die Lock-Datei bereits existiert
* oder nicht angelegt werden kann
*/ */
@Override @Override
public void acquire() { public void acquire() {
if (Files.exists(lockFile)) { if (Files.exists(lockFile)) {
throw new RunLockUnavailableException( throw new RunLockUnavailableException(
"Run lock file already exists - another instance may be running: " + lockFile); "Run-Lock-Datei existiert bereits eine andere Instanz könnte laufen: " + lockFile);
} }
try { try {
Path parent = lockFile.getParent(); Path parent = lockFile.getParent();
@@ -57,26 +65,83 @@ public class FilesystemRunLockPortAdapter implements RunLockPort {
} }
long pid = ProcessHandle.current().pid(); long pid = ProcessHandle.current().pid();
Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW); Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW);
LOG.debug("Run lock acquired: {} (PID {})", lockFile, pid); LOG.debug("Run-Lock erworben: {} (PID {})", lockFile, pid);
} catch (IOException e) { } catch (IOException e) {
throw new RunLockUnavailableException("Failed to acquire run lock file: " + lockFile, e); throw new RunLockUnavailableException("Run-Lock-Datei konnte nicht angelegt werden: " + lockFile, e);
} }
} }
/** /**
* Releases the run lock by deleting the lock file. * Gibt den Run-Lock durch Löschen der Lock-Datei frei.
* <p> * <p>
* If deletion fails, a warning is logged but no exception is thrown. * Schlägt das Löschen fehl, wird eine Warnung geloggt; keine Ausnahme
* wird geworfen.
*/ */
@Override @Override
public void release() { public void release() {
try { try {
boolean deleted = Files.deleteIfExists(lockFile); boolean deleted = Files.deleteIfExists(lockFile);
if (deleted) { if (deleted) {
LOG.debug("Run lock released: {}", lockFile); LOG.debug("Run-Lock freigegeben: {}", lockFile);
} }
} catch (IOException e) { } catch (IOException e) {
LOG.warn("Failed to release run lock file: {} manual cleanup may be required", lockFile, e); LOG.warn("Run-Lock-Datei konnte nicht gelöscht werden: {} manuelle Bereinigung erforderlich",
lockFile, e);
}
}
/**
* Versucht nicht-blockierend, den Run-Lock zu erwerben.
* <p>
* Existiert die Lock-Datei bereits, wird sofort {@link Optional#empty()}
* zurückgegeben. Andernfalls wird die Datei atomar mit
* {@link StandardOpenOption#CREATE_NEW} angelegt. Schlägt das Anlegen
* aufgrund einer Race-Condition fehl (z.B. gleichzeitiger Erwerb durch
* eine andere Instanz), wird ebenfalls {@link Optional#empty()} zurückgegeben.
* <p>
* Das zurückgegebene {@link RunLockHandle} gibt den Lock idempotent frei.
*
* @return Handle mit dem erworbenen Lock, oder {@link Optional#empty()}
* wenn der Lock nicht verfügbar ist
*/
@Override
public Optional<RunLockHandle> tryAcquire() {
if (Files.exists(lockFile)) {
LOG.debug("Run-Lock nicht verfügbar (Datei existiert): {}", lockFile);
return Optional.empty();
}
try {
Path parent = lockFile.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
long pid = ProcessHandle.current().pid();
Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW);
LOG.debug("Run-Lock (tryAcquire) erworben: {} (PID {})", lockFile, pid);
return Optional.of(new FilesystemRunLockHandle());
} catch (IOException e) {
// CREATE_NEW schlägt mit FileAlreadyExistsException fehl wenn eine
// Race-Condition vorliegt kein Fehler, sondern normaler Busy-Zustand
LOG.debug("Run-Lock (tryAcquire) nicht verfügbar: {} {}", lockFile, e.getMessage());
return Optional.empty();
}
}
/**
* Handle für einen über {@link #tryAcquire()} erworbenen Run-Lock.
* <p>
* Gibt den Lock idempotent frei. Mehrfaches Aufrufen von {@link #close()}
* hat nach dem ersten Aufruf keine Wirkung.
*/
private class FilesystemRunLockHandle implements RunLockHandle {
private final AtomicBoolean released = new AtomicBoolean(false);
@Override
public void close() {
if (released.compareAndSet(false, true)) {
FilesystemRunLockPortAdapter.this.release();
}
} }
} }
} }
@@ -46,6 +46,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalog
* </ul> * </ul>
*/ */
public class ClaudeModelCatalogAdapter implements AiModelCatalogPort { public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
private static final String FAILURE_CODE_CONNECTION = "CONNECTION_FAILURE";
private static final String FAILURE_CODE_UNKNOWN = "UNKNOWN";
private static final Logger LOG = LogManager.getLogger(ClaudeModelCatalogAdapter.class); private static final Logger LOG = LogManager.getLogger(ClaudeModelCatalogAdapter.class);
@@ -133,28 +137,28 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
} catch (java.net.http.HttpTimeoutException e) { } catch (java.net.http.HttpTimeoutException e) {
LOG.warn("Claude model catalogue: request timed out {}", e.getMessage()); LOG.warn("Claude model catalogue: request timed out {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Zeitüberschreitung beim Modellabruf: " + e.getMessage()); "Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) { } catch (java.net.ConnectException e) {
LOG.warn("Claude model catalogue: connection failed {}", e.getMessage()); LOG.warn("Claude model catalogue: connection failed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage()); "Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) { } catch (java.net.UnknownHostException e) {
LOG.warn("Claude model catalogue: hostname not resolvable {}", e.getMessage()); LOG.warn("Claude model catalogue: hostname not resolvable {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Hostname nicht auflösbar: " + e.getMessage()); "Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) { } catch (java.io.IOException e) {
LOG.warn("Claude model catalogue: IO error {}", e.getMessage()); LOG.warn("Claude model catalogue: IO error {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"E/A-Fehler beim Modellabruf: " + e.getMessage()); "E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
LOG.warn("Claude model catalogue: request interrupted"); LOG.warn("Claude model catalogue: request interrupted");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Modellabruf wurde unterbrochen."); "Modellabruf wurde unterbrochen.");
} catch (Exception e) { } catch (Exception e) {
LOG.error("Claude model catalogue: unexpected error", e); LOG.error("Claude model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter Fehler: " + e.getMessage()); "Unerwarteter Fehler: " + e.getMessage());
} }
} }
@@ -188,7 +192,7 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
if (status != 200) { if (status != 200) {
LOG.warn("Claude model catalogue: unexpected HTTP status {}", status); LOG.warn("Claude model catalogue: unexpected HTTP status {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter HTTP-Status: " + status); "Unerwarteter HTTP-Status: " + status);
} }
@@ -291,24 +295,24 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
return handleResponse(response); return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) { } catch (java.net.http.HttpTimeoutException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Zeitüberschreitung beim Modellabruf: " + e.getMessage()); "Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) { } catch (java.net.ConnectException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage()); "Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) { } catch (java.net.UnknownHostException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Hostname nicht auflösbar: " + e.getMessage()); "Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) { } catch (java.io.IOException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"E/A-Fehler beim Modellabruf: " + e.getMessage()); "E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Modellabruf wurde unterbrochen."); "Modellabruf wurde unterbrochen.");
} catch (Exception e) { } catch (Exception e) {
LOG.error("Claude model catalogue: unexpected error", e); LOG.error("Claude model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter Fehler: " + e.getMessage()); "Unerwarteter Fehler: " + e.getMessage());
} }
} }
@@ -46,6 +46,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalog
* </ul> * </ul>
*/ */
public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort { public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
private static final String FAILURE_CODE_CONNECTION = "CONNECTION_FAILURE";
private static final String FAILURE_CODE_UNKNOWN = "UNKNOWN";
private static final Logger LOG = LogManager.getLogger(OpenAiCompatibleModelCatalogAdapter.class); private static final Logger LOG = LogManager.getLogger(OpenAiCompatibleModelCatalogAdapter.class);
@@ -129,28 +133,28 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
} catch (java.net.http.HttpTimeoutException e) { } catch (java.net.http.HttpTimeoutException e) {
LOG.warn("OpenAI-compatible model catalogue: request timed out {}", e.getMessage()); LOG.warn("OpenAI-compatible model catalogue: request timed out {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Zeitüberschreitung beim Modellabruf: " + e.getMessage()); "Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) { } catch (java.net.ConnectException e) {
LOG.warn("OpenAI-compatible model catalogue: connection failed {}", e.getMessage()); LOG.warn("OpenAI-compatible model catalogue: connection failed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage()); "Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) { } catch (java.net.UnknownHostException e) {
LOG.warn("OpenAI-compatible model catalogue: hostname not resolvable {}", e.getMessage()); LOG.warn("OpenAI-compatible model catalogue: hostname not resolvable {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Hostname nicht auflösbar: " + e.getMessage()); "Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) { } catch (java.io.IOException e) {
LOG.warn("OpenAI-compatible model catalogue: IO error {}", e.getMessage()); LOG.warn("OpenAI-compatible model catalogue: IO error {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"E/A-Fehler beim Modellabruf: " + e.getMessage()); "E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
LOG.warn("OpenAI-compatible model catalogue: request interrupted"); LOG.warn("OpenAI-compatible model catalogue: request interrupted");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Modellabruf wurde unterbrochen."); "Modellabruf wurde unterbrochen.");
} catch (Exception e) { } catch (Exception e) {
LOG.error("OpenAI-compatible model catalogue: unexpected error", e); LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter Fehler: " + e.getMessage()); "Unerwarteter Fehler: " + e.getMessage());
} }
} }
@@ -184,7 +188,7 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
if (status != 200) { if (status != 200) {
LOG.warn("OpenAI-compatible model catalogue: unexpected HTTP status {}", status); LOG.warn("OpenAI-compatible model catalogue: unexpected HTTP status {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter HTTP-Status: " + status); "Unerwarteter HTTP-Status: " + status);
} }
@@ -285,24 +289,24 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
return handleResponse(response); return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) { } catch (java.net.http.HttpTimeoutException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Zeitüberschreitung beim Modellabruf: " + e.getMessage()); "Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) { } catch (java.net.ConnectException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage()); "Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) { } catch (java.net.UnknownHostException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Hostname nicht auflösbar: " + e.getMessage()); "Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) { } catch (java.io.IOException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"E/A-Fehler beim Modellabruf: " + e.getMessage()); "E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
"Modellabruf wurde unterbrochen."); "Modellabruf wurde unterbrochen.");
} catch (Exception e) { } catch (Exception e) {
LOG.error("OpenAI-compatible model catalogue: unexpected error", e); LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN", return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
"Unerwarteter Fehler: " + e.getMessage()); "Unerwarteter Fehler: " + e.getMessage());
} }
} }
@@ -48,6 +48,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
* werden propagiert. * werden propagiert.
*/ */
public class FilesystemPromptPortAdapter implements PromptPort { public class FilesystemPromptPortAdapter implements PromptPort {
private static final String SAVE_FAILED_LOG_MSG = "Prompt speichern fehlgeschlagen: {}";
private static final Logger LOG = LogManager.getLogger(FilesystemPromptPortAdapter.class); private static final Logger LOG = LogManager.getLogger(FilesystemPromptPortAdapter.class);
@@ -125,7 +127,7 @@ public class FilesystemPromptPortAdapter implements PromptPort {
if (targetDir == null || !Files.isDirectory(targetDir)) { if (targetDir == null || !Files.isDirectory(targetDir)) {
String message = "Zielordner der Prompt-Datei existiert nicht: " String message = "Zielordner der Prompt-Datei existiert nicht: "
+ (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt"); + (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
LOG.warn("Prompt speichern fehlgeschlagen: {}", message); LOG.warn(SAVE_FAILED_LOG_MSG, message);
return new PromptSaveResult.TargetDirectoryMissing(message); return new PromptSaveResult.TargetDirectoryMissing(message);
} }
@@ -138,7 +140,7 @@ public class FilesystemPromptPortAdapter implements PromptPort {
} catch (IOException e) { } catch (IOException e) {
beräumeTempDatei(tempFile); beräumeTempDatei(tempFile);
String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage(); String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e); LOG.warn(SAVE_FAILED_LOG_MSG, message, e);
return new PromptSaveResult.WriteFailed(message, e); return new PromptSaveResult.WriteFailed(message, e);
} }
@@ -155,7 +157,7 @@ public class FilesystemPromptPortAdapter implements PromptPort {
} catch (IOException e) { } catch (IOException e) {
beräumeTempDatei(tempFile); beräumeTempDatei(tempFile);
String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage(); String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e); LOG.warn(SAVE_FAILED_LOG_MSG, message, e);
return new PromptSaveResult.AtomicMoveFailed(message); return new PromptSaveResult.AtomicMoveFailed(message);
} }
} }
@@ -43,6 +43,8 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceC
* Ausnahmen an den Aufrufer weitergegeben. * Ausnahmen an den Aufrufer weitergegeben.
*/ */
public class FilesystemResourceCreationAdapter implements ResourceCreationPort { public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
private static final String INVALID_PATH_PREFIX = "Ungültiger Pfad: ";
private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class); private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class);
@@ -66,7 +68,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) { public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
Path path = toPath(suggestion.path()); Path path = toPath(suggestion.path());
if (path == null) { if (path == null) {
String msg = "Ungültiger Pfad: " + suggestion.path(); String msg = INVALID_PATH_PREFIX + suggestion.path();
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg); LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg); return new CorrectionOutcome.Failed(suggestion, msg);
} }
@@ -114,7 +116,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) { public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
Path path = toPath(suggestion.path()); Path path = toPath(suggestion.path());
if (path == null) { if (path == null) {
String msg = "Ungültiger Pfad: " + suggestion.path(); String msg = INVALID_PATH_PREFIX + suggestion.path();
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg); LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg); return new CorrectionOutcome.Failed(suggestion, msg);
} }
@@ -164,7 +166,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) { public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
Path path = toPath(suggestion.path()); Path path = toPath(suggestion.path());
if (path == null) { if (path == null) {
String msg = "Ungültiger Pfad: " + suggestion.path(); String msg = INVALID_PATH_PREFIX + suggestion.path();
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", msg); LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg); return new CorrectionOutcome.Failed(suggestion, msg);
} }
@@ -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;
}
}
@@ -43,6 +43,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* application/domain type. * application/domain type.
*/ */
public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttemptRepository { public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttemptRepository {
private static final String FINGERPRINT_NOT_NULL = "fingerprint must not be null";
private static final Logger logger = LogManager.getLogger(SqliteProcessingAttemptRepositoryAdapter.class); private static final Logger logger = LogManager.getLogger(SqliteProcessingAttemptRepositoryAdapter.class);
@@ -78,7 +80,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/ */
@Override @Override
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) { public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null"); Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
String sql = """ String sql = """
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
@@ -159,7 +161,8 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
statement.setString(5, attempt.endedAt().toString()); statement.setString(5, attempt.endedAt().toString());
statement.setString(6, attempt.status().name()); statement.setString(6, attempt.status().name());
setNullableString(statement, 7, attempt.failureClass()); setNullableString(statement, 7, attempt.failureClass());
setNullableString(statement, 8, attempt.failureMessage()); // 1000-Zeichen-Grenze erzwingen; längere Meldungen werden mit " markiert
setNullableString(statement, 8, truncateFailureMessage(attempt.failureMessage()));
statement.setBoolean(9, attempt.retryable()); statement.setBoolean(9, attempt.retryable());
// AI provider identifier and AI traceability fields // AI provider identifier and AI traceability fields
setNullableString(statement, 10, attempt.aiProvider()); setNullableString(statement, 10, attempt.aiProvider());
@@ -203,7 +206,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/ */
@Override @Override
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) { public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null"); Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
String sql = """ String sql = """
SELECT SELECT
@@ -254,7 +257,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/ */
@Override @Override
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) { public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null"); Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
String sql = """ String sql = """
SELECT SELECT
@@ -360,6 +363,27 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
} }
} }
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Kürzt eine Fehlermeldung auf maximal 1000 Zeichen vor der Persistierung.
* Längere Meldungen werden mit " markiert.
*
* @param message die ursprüngliche Fehlermeldung; kann {@code null} sein
* @return die (ggf. gekürzte) Meldung oder {@code null}
*/
private static String truncateFailureMessage(String message) {
if (message == null) {
return null;
}
if (message.length() <= 1000) {
return message;
}
return message.substring(0, 997) + "";
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// JDBC nullable helpers // JDBC nullable helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -400,7 +424,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/ */
@Override @Override
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) { public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null"); Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?"; String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?";
@@ -9,7 +9,6 @@ import java.sql.Connection;
import java.sql.DatabaseMetaData; import java.sql.DatabaseMetaData;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.Instant;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
@@ -63,6 +62,11 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
* Domain-/Application-Typen erscheinen keine JDBC- oder SQLite-Typen. * Domain-/Application-Typen erscheinen keine JDBC- oder SQLite-Typen.
*/ */
public class SqliteSchemaInitializationAdapter implements PersistenceSchemaInitializationPort { public class SqliteSchemaInitializationAdapter implements PersistenceSchemaInitializationPort {
private static final String TABLE_DOCUMENT_RECORD = "document_record";
private static final String TABLE_PROCESSING_ATTEMPT = "processing_attempt";
private static final String COL_FINGERPRINT = "fingerprint";
private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class); private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class);
@@ -72,7 +76,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
/** Alle erwarteten Spalten der Tabelle {@code document_record}. */ /** Alle erwarteten Spalten der Tabelle {@code document_record}. */
private static final Set<String> EXPECTED_COLUMNS_DOCUMENT_RECORD = Set.of( private static final Set<String> EXPECTED_COLUMNS_DOCUMENT_RECORD = Set.of(
"id", "fingerprint", "last_known_source_locator", "last_known_source_file_name", "id", COL_FINGERPRINT, "last_known_source_locator", "last_known_source_file_name",
"overall_status", "content_error_count", "transient_error_count", "overall_status", "content_error_count", "transient_error_count",
"last_failure_instant", "last_success_instant", "created_at", "updated_at", "last_failure_instant", "last_success_instant", "created_at", "updated_at",
"last_target_path", "last_target_file_name" "last_target_path", "last_target_file_name"
@@ -80,7 +84,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
/** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */ /** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of( private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
"id", "fingerprint", "run_id", "attempt_number", "started_at", "ended_at", "id", COL_FINGERPRINT, "run_id", "attempt_number", "started_at", "ended_at",
"status", "failure_class", "failure_message", "retryable", "status", "failure_class", "failure_message", "retryable",
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count", "model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source", "ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
@@ -287,8 +291,8 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
return DbState.FLYWAY_MANAGED; return DbState.FLYWAY_MANAGED;
} }
// "Leer" = keine Tabellen vorhanden (unabhängig von Dateigröße) // "Leer" = keine Tabellen vorhanden (unabhängig von Dateigröße)
boolean hasFachlicheTabellen = tables.contains("document_record") boolean hasFachlicheTabellen = tables.contains(TABLE_DOCUMENT_RECORD)
|| tables.contains("processing_attempt"); || tables.contains(TABLE_PROCESSING_ATTEMPT);
if (hasFachlicheTabellen) { if (hasFachlicheTabellen) {
return DbState.EXISTING_WITHOUT_FLYWAY; return DbState.EXISTING_WITHOUT_FLYWAY;
} }
@@ -321,25 +325,25 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
// Tabellen prüfen // Tabellen prüfen
Set<String> tabellen = readTableNames(meta); Set<String> tabellen = readTableNames(meta);
if (!tabellen.contains("document_record")) { if (!tabellen.contains(TABLE_DOCUMENT_RECORD)) {
fehler.add("Tabelle 'document_record' fehlt"); fehler.add("Tabelle 'document_record' fehlt");
} }
if (!tabellen.contains("processing_attempt")) { if (!tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
fehler.add("Tabelle 'processing_attempt' fehlt"); fehler.add("Tabelle 'processing_attempt' fehlt");
} }
// Spalten prüfen nur wenn Tabellen vorhanden // Spalten prüfen nur wenn Tabellen vorhanden
if (tabellen.contains("document_record")) { if (tabellen.contains(TABLE_DOCUMENT_RECORD)) {
pruefeSpaltenvollstaendigkeit(meta, "document_record", pruefeSpaltenvollstaendigkeit(meta, TABLE_DOCUMENT_RECORD,
EXPECTED_COLUMNS_DOCUMENT_RECORD, fehler); EXPECTED_COLUMNS_DOCUMENT_RECORD, fehler);
} }
if (tabellen.contains("processing_attempt")) { if (tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
pruefeSpaltenvollstaendigkeit(meta, "processing_attempt", pruefeSpaltenvollstaendigkeit(meta, TABLE_PROCESSING_ATTEMPT,
EXPECTED_COLUMNS_PROCESSING_ATTEMPT, fehler); EXPECTED_COLUMNS_PROCESSING_ATTEMPT, fehler);
} }
// Indizes prüfen // Indizes prüfen
if (tabellen.contains("document_record") && tabellen.contains("processing_attempt")) { if (tabellen.contains(TABLE_DOCUMENT_RECORD) && tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
Set<String> vorhandeneIndizes = readIndexNames(meta); Set<String> vorhandeneIndizes = readIndexNames(meta);
for (String erwartetIndex : EXPECTED_INDEXES) { for (String erwartetIndex : EXPECTED_INDEXES) {
if (!vorhandeneIndizes.contains(erwartetIndex)) { if (!vorhandeneIndizes.contains(erwartetIndex)) {
@@ -349,10 +353,10 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
} }
// Constraints prüfen (soweit per Metadata prüfbar) // Constraints prüfen (soweit per Metadata prüfbar)
if (tabellen.contains("document_record")) { if (tabellen.contains(TABLE_DOCUMENT_RECORD)) {
pruefeUniqueConstraintAufFingerprint(conn, fehler); pruefeUniqueConstraintAufFingerprint(conn, fehler);
} }
if (tabellen.contains("processing_attempt")) { if (tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
pruefeForeignKeyAufDocumentRecord(conn, fehler); pruefeForeignKeyAufDocumentRecord(conn, fehler);
} }
@@ -400,10 +404,10 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
private void pruefeUniqueConstraintAufFingerprint(Connection conn, private void pruefeUniqueConstraintAufFingerprint(Connection conn,
List<String> fehler) throws SQLException { List<String> fehler) throws SQLException {
boolean uniqueGefunden = false; boolean uniqueGefunden = false;
try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, "document_record", true, false)) { try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, TABLE_DOCUMENT_RECORD, true, false)) {
while (rs.next()) { while (rs.next()) {
String spalte = rs.getString("COLUMN_NAME"); String spalte = rs.getString("COLUMN_NAME");
if ("fingerprint".equalsIgnoreCase(spalte)) { if (COL_FINGERPRINT.equalsIgnoreCase(spalte)) {
uniqueGefunden = true; uniqueGefunden = true;
break; break;
} }
@@ -425,12 +429,12 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
private void pruefeForeignKeyAufDocumentRecord(Connection conn, private void pruefeForeignKeyAufDocumentRecord(Connection conn,
List<String> fehler) throws SQLException { List<String> fehler) throws SQLException {
boolean fkGefunden = false; boolean fkGefunden = false;
try (ResultSet rs = conn.getMetaData().getImportedKeys(null, null, "processing_attempt")) { try (ResultSet rs = conn.getMetaData().getImportedKeys(null, null, TABLE_PROCESSING_ATTEMPT)) {
while (rs.next()) { while (rs.next()) {
String pkTabelle = rs.getString("PKTABLE_NAME"); String pkTabelle = rs.getString("PKTABLE_NAME");
String fkSpalte = rs.getString("FKCOLUMN_NAME"); String fkSpalte = rs.getString("FKCOLUMN_NAME");
if ("document_record".equalsIgnoreCase(pkTabelle) if (TABLE_DOCUMENT_RECORD.equalsIgnoreCase(pkTabelle)
&& "fingerprint".equalsIgnoreCase(fkSpalte)) { && COL_FINGERPRINT.equalsIgnoreCase(fkSpalte)) {
fkGefunden = true; fkGefunden = true;
break; break;
} }
@@ -562,7 +566,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
*/ */
private static Set<String> readIndexNames(DatabaseMetaData meta) throws SQLException { private static Set<String> readIndexNames(DatabaseMetaData meta) throws SQLException {
Set<String> names = new HashSet<>(); Set<String> names = new HashSet<>();
for (String tabelle : new String[]{"document_record", "processing_attempt"}) { for (String tabelle : new String[]{TABLE_DOCUMENT_RECORD, TABLE_PROCESSING_ATTEMPT}) {
try (ResultSet rs = meta.getIndexInfo(null, null, tabelle, false, false)) { try (ResultSet rs = meta.getIndexInfo(null, null, tabelle, false, false)) {
while (rs.next()) { while (rs.next()) {
String indexName = rs.getString("INDEX_NAME"); String indexName = rs.getString("INDEX_NAME");
@@ -24,6 +24,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* and processing attempt repositories. * and processing attempt repositories.
*/ */
public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort { public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
private static final String ROLLBACK_FAILED_MSG = "Rollback fehlgeschlagen: {}";
private static final Logger logger = LogManager.getLogger(SqliteUnitOfWorkAdapter.class); private static final Logger logger = LogManager.getLogger(SqliteUnitOfWorkAdapter.class);
@@ -57,7 +59,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
connection.rollback(); connection.rollback();
logger.debug("Transaktion zurückgerollt (Dokumentfehler): {}", e.getMessage()); logger.debug("Transaktion zurückgerollt (Dokumentfehler): {}", e.getMessage());
} catch (SQLException rollbackEx) { } catch (SQLException rollbackEx) {
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx); logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
} }
throw e; throw e;
} catch (RuntimeException e) { } catch (RuntimeException e) {
@@ -66,7 +68,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
connection.rollback(); connection.rollback();
logger.debug("Transaktion zurückgerollt (Laufzeitfehler): {}", e.getMessage()); logger.debug("Transaktion zurückgerollt (Laufzeitfehler): {}", e.getMessage());
} catch (SQLException rollbackEx) { } catch (SQLException rollbackEx) {
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx); logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
} }
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e); throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
} catch (SQLException e) { } catch (SQLException e) {
@@ -75,7 +77,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
connection.rollback(); connection.rollback();
logger.debug("Transaktion zurückgerollt (SQL-Fehler): {}", e.getMessage()); logger.debug("Transaktion zurückgerollt (SQL-Fehler): {}", e.getMessage());
} catch (SQLException rollbackEx) { } catch (SQLException rollbackEx) {
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx); logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
} }
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e); throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
} }
@@ -214,10 +214,20 @@ class AnthropicClaudeAdapterIntegrationTest {
* where log output is not relevant to the assertion. * where log output is not relevant to the assertion.
*/ */
private static class NoOpProcessingLogger implements ProcessingLogger { private static class NoOpProcessingLogger implements ProcessingLogger {
@Override public void info(String message, Object... args) {} @Override public void info(String message, Object... args) {
@Override public void debug(String message, Object... args) {} // intentionally empty
@Override public void warn(String message, Object... args) {} }
@Override public void error(String message, Object... args) {} @Override public void debug(String message, Object... args) {
@Override public void debugSensitiveAiContent(String message, Object... args) {} // intentionally empty
}
@Override public void warn(String message, Object... args) {
// intentionally empty
}
@Override public void error(String message, Object... args) {
// intentionally empty
}
@Override public void debugSensitiveAiContent(String message, Object... args) {
// intentionally empty
}
} }
} }
@@ -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();
}
}
@@ -1,6 +1,7 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.file.Files; import java.nio.file.Files;
@@ -15,9 +16,6 @@ import java.util.Set;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import org.sqlite.SQLiteConfig;
import org.sqlite.SQLiteDataSource;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
/** /**
@@ -195,12 +193,14 @@ class SqliteSchemaInitializationAdapterTest {
String jdbcUrl = jdbcUrl(dir, "fall3.db"); String jdbcUrl = jdbcUrl(dir, "fall3.db");
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl); SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
// Erster Aufruf (Fall 1) assertThatCode(() -> {
adapter.initializeSchema(); // Erster Aufruf (Fall 1)
// Zweiter Aufruf (Fall 3) darf nicht werfen adapter.initializeSchema();
adapter.initializeSchema(); // Zweiter Aufruf (Fall 3) darf nicht werfen
// Dritter Aufruf (Fall 3) ebenfalls idempotent adapter.initializeSchema();
adapter.initializeSchema(); // Dritter Aufruf (Fall 3) ebenfalls idempotent
adapter.initializeSchema();
}).doesNotThrowAnyException();
} }
@Test @Test
@@ -256,16 +256,19 @@ class SqliteSchemaInitializationAdapterTest {
ds.setUrl(jdbcUrl); ds.setUrl(jdbcUrl);
try (Connection conn = ds.getConnection()) { try (Connection conn = ds.getConnection()) {
assertThatThrownBy(() -> { assertThatThrownBy(() -> insertOrphanedProcessingAttempt(conn))
try (var ps = conn.prepareStatement(""" .isInstanceOf(SQLException.class);
INSERT INTO processing_attempt }
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable) }
VALUES ('nichtvorhanden', 'run-1', 1, '2026-01-01T00:00:00Z',
'2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1) private static void insertOrphanedProcessingAttempt(Connection conn) throws SQLException {
""")) { try (var ps = conn.prepareStatement("""
ps.executeUpdate(); INSERT INTO processing_attempt
} (fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
}).isInstanceOf(SQLException.class); VALUES ('nichtvorhanden', 'run-1', 1, '2026-01-01T00:00:00Z',
'2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1)
""")) {
ps.executeUpdate();
} }
} }
@@ -1,6 +1,7 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder; package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatNullPointerException; import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import java.io.IOException; import java.io.IOException;
@@ -219,8 +220,8 @@ class FilesystemTargetFolderAdapterTest {
@Test @Test
void tryDeleteTargetFile_fileDoesNotExist_doesNotThrow() { void tryDeleteTargetFile_fileDoesNotExist_doesNotThrow() {
// Must not throw even if the file is absent assertThatCode(() -> adapter.tryDeleteTargetFile("nonexistent.pdf"))
adapter.tryDeleteTargetFile("nonexistent.pdf"); .doesNotThrowAnyException();
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -0,0 +1,142 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.nio.file.Path;
import java.util.Objects;
/**
* Inbound-Port zum Anlegen einer neuen, leeren SQLite-Datenbankdatei und zum Umstellen
* der aktiven Datenbankreferenz der Anwendung auf diese neue Datei.
* <p>
* Der Use-Case orchestriert den vollständigen, aus Anwendungssicht atomaren Ablauf:
* <ol>
* <li>Pfad-Sicherheitsprüfung: aktive DB darf nicht überschrieben werden;</li>
* <li>Erzeugung einer temporären SQLite-Datei im Zielverzeichnis;</li>
* <li>vollständige Schema-Migration auf den neuesten Stand;</li>
* <li>Verbindungstest gegen die migrierte Temp-Datei;</li>
* <li>atomarer Move auf den endgültigen Zielpfad
* ({@link java.nio.file.StandardCopyOption#ATOMIC_MOVE},
* {@link java.nio.file.StandardCopyOption#REPLACE_EXISTING});</li>
* <li>Umstellung der aktiven DB-Referenz über den
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}.</li>
* </ol>
* <p>
* Schlägt ein Schritt fehl, bleibt die bisher aktive DB unverändert in Betrieb. Die
* temporäre Datei wird im Fehlerfall zuverlässig entfernt.
*/
@FunctionalInterface
public interface CreateNewDatabaseUseCase {
/**
* Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und stellt
* die aktive Datenbankreferenz der Anwendung auf diese Datei um.
*
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null}
* sein. Bei einer bereits existierenden Datei muss der Aufrufer
* vorab die Bestätigung des Benutzers eingeholt haben.
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie
* {@code null}
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
*/
CreateNewDatabaseResult createNewDatabase(Path targetFile);
/**
* Versiegeltes Ergebnis-Interface für {@link CreateNewDatabaseUseCase#createNewDatabase(Path)}.
*/
sealed interface CreateNewDatabaseResult
permits CreateNewDatabaseResult.Success,
CreateNewDatabaseResult.SameAsActiveDatabase,
CreateNewDatabaseResult.CreationFailed {
/**
* Erfolgsfall. Die neue Datenbank wurde angelegt, migriert, getestet und ist
* jetzt die aktive Datenbank der Anwendung.
*
* @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; nie
* {@code null}
*/
record Success(Path targetFile) implements CreateNewDatabaseResult {
/**
* Konstruktor mit Pflichtprüfung.
*
* @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; darf
* nicht {@code null} sein
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
*/
public Success {
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
}
}
/**
* Fehlerfall: Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei.
* Diese darf nicht überschrieben werden. Die aktive DB bleibt unverändert.
*
* @param targetFile der vom Benutzer gewählte Zielpfad; nie {@code null}
*/
record SameAsActiveDatabase(Path targetFile) implements CreateNewDatabaseResult {
/**
* Konstruktor mit Pflichtprüfung.
*
* @param targetFile der vom Benutzer gewählte Zielpfad; darf nicht
* {@code null} sein
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
*/
public SameAsActiveDatabase {
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
}
}
/**
* Fehlerfall: Beim Anlegen, Migrieren, Testen oder beim atomaren Move ist ein
* technischer Fehler aufgetreten. Die aktive DB bleibt unverändert; eine evtl.
* angelegte Temp-Datei wurde entfernt.
*
* @param phase technische Phase, in der der Fehler auftrat; nie {@code null}
* @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
* @param cause ursächliche Ausnahme; kann {@code null} sein
*/
record CreationFailed(Phase phase, String message, Throwable cause)
implements CreateNewDatabaseResult {
/**
* Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
*
* @param phase technische Phase; darf nicht {@code null} sein
* @param message kurze, deutsche Fehlerbeschreibung; darf nicht
* {@code null} sein
* @param cause ursächliche Ausnahme; kann {@code null} sein
* @throws NullPointerException wenn {@code phase} oder {@code message}
* {@code null} ist
*/
public CreationFailed {
Objects.requireNonNull(phase, "phase darf nicht null sein");
Objects.requireNonNull(message, "message darf nicht null sein");
}
}
/**
* Technische Phase, in der ein Fehler aufgetreten ist.
*/
enum Phase {
/** Pfad-Sicherheitsprüfung (z. B. Auflösung über {@code toRealPath()}) ist fehlgeschlagen. */
PATH_RESOLUTION,
/** Anlage der temporären Datei ist fehlgeschlagen. */
FILE_CREATION,
/** Schema-Migration der temporären Datei ist fehlgeschlagen. */
SCHEMA_MIGRATION,
/** Verbindungstest gegen die migrierte Datei ist fehlgeschlagen. */
CONNECTION_TEST,
/**
* Atomarer Move der temporären Datei zum Zielpfad ist fehlgeschlagen
* insbesondere wenn das Dateisystem die Kombination
* {@code ATOMIC_MOVE + REPLACE_EXISTING} nicht unterstützt. Es wird
* absichtlich kein nicht-atomarer Fallback durchgeführt.
*/
ATOMIC_MOVE,
/** Umstellung der aktiven DB-Referenz ist fehlgeschlagen. */
CONTEXT_SWITCH
}
}
}
@@ -1,6 +1,7 @@
package de.gecheckt.pdf.umbenenner.application.port.in; package de.gecheckt.pdf.umbenenner.application.port.in;
import java.time.Instant; import java.time.Instant;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -37,9 +38,9 @@ public record HistoricalDocumentContext(
* {@code lastFailureInstant} {@code null} sind * {@code lastFailureInstant} {@code null} sind
*/ */
public HistoricalDocumentContext { public HistoricalDocumentContext {
lastTargetFileName = lastTargetFileName == null ? Optional.empty() : lastTargetFileName; lastTargetFileName = Objects.requireNonNullElse(lastTargetFileName, Optional.empty());
lastSuccessInstant = lastSuccessInstant == null ? Optional.empty() : lastSuccessInstant; lastSuccessInstant = Objects.requireNonNullElse(lastSuccessInstant, Optional.empty());
lastFailureInstant = lastFailureInstant == null ? Optional.empty() : lastFailureInstant; lastFailureInstant = Objects.requireNonNullElse(lastFailureInstant, Optional.empty());
} }
/** /**
@@ -0,0 +1,86 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Inbound-Port zur Steuerung des automatischen Schedulers.
* <p>
* Dieser Use Case kapselt den vollständigen Scheduler-Lifecycle:
* Starten, Stoppen und Abfragen des aktuellen Zustands. Die Steuerung
* erfolgt ausschließlich über dieses Interface GUI-Komponenten kennen
* weder den {@code SchedulerPort} noch Bootstrap-interne Typen.
* <p>
* Alle Operationen sind idempotent: Ein {@link #start()} auf einem
* bereits laufenden Scheduler ist ein No-op; ein {@link #stop()} auf
* einem bereits gestoppten Scheduler ebenso.
* <p>
* Implementierungen verwalten den Zustand threadsicher über eine
* {@code AtomicReference<SchedulerStatus>}.
*/
public interface SchedulerControlUseCase {
/**
* Startet den automatischen Scheduler.
* <p>
* Ist der Scheduler bereits aktiv ({@code state != STOPPED}), hat
* dieser Aufruf keine Wirkung. Andernfalls wird der Scheduler über
* folgende Sequenz gestartet:
* <ol>
* <li>Zustand auf {@code STARTING} setzen</li>
* <li>Exklusiven OS-Lock auf Konfigurationsdatei erwerben</li>
* <li>Scheduler-Adapter starten (erster Tick sofort)</li>
* <li>Zustand auf {@code RUNNING_IDLE} setzen</li>
* </ol>
* Schlägt ein Schritt fehl, wird ein vollständiger Rollback
* durchgeführt und der Zustand auf {@code STOPPED} zurückgesetzt.
*
* @throws SchedulerStartException wenn der Start fehlschlägt und
* kein Rollback möglich ist; enthält eine deutsche Meldung
* für die GUI-Anzeige
*/
void start() throws SchedulerStartException;
/**
* Stoppt den automatischen Scheduler.
* <p>
* Ist der Scheduler bereits gestoppt, hat dieser Aufruf keine Wirkung.
* Läuft gerade ein Tick, wechselt der Zustand zu
* {@code STOPPING_BATCH_ACTIVE}; der laufende Batch wird regulär
* zu Ende geführt. Danach wird der OS-Lock freigegeben.
*/
void stop();
/**
* Gibt den aktuellen Scheduler-Zustand als unveränderlichen Snapshot zurück.
* <p>
* Der Snapshot kann von beliebigen Threads gelesen werden. Die GUI
* ruft diese Methode regelmäßig über eine zentrale Status-Refresh-Timeline
* auf und aktualisiert alle betroffenen Tabs entsprechend.
*
* @return aktueller Scheduler-Status; nie {@code null}
*/
SchedulerStatus getStatus();
/**
* Gibt das aktuell konfigurierte Ausführungsintervall in Sekunden zurück.
* <p>
* Wird vom Scheduler-Tab genutzt, um den Initialwert des Intervall-Feldes
* anzuzeigen. Der Wert entspricht dem beim Start der Anwendung geladenen
* Konfigurationswert (mindestens 30 Sekunden).
*
* @return Intervall in Sekunden; immer &ge; 30
*/
int getIntervalSeconds();
/**
* Persistiert das Ausführungsintervall in die Konfigurationsdatei.
* <p>
* Sicher nur aufzurufen wenn der Scheduler gestoppt ist. Der in-Memory-Wert
* wird nicht aktualisiert; der neue Wert wird beim nächsten Anwendungsstart
* gelesen.
* <p>
* Muss auf einem Hintergrund-Thread aufgerufen werden, da der Schreibvorgang
* den Konfigurations-Datei-Lock erwerben muss.
*
* @param seconds Intervall in Sekunden; sollte &ge; 30 sein
*/
void saveIntervalSeconds(int seconds);
}
@@ -0,0 +1,58 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Aggregierte Zähler über alle abgeschlossenen Ticks der laufenden bzw. zuletzt
* gelaufenen Scheduler-Sitzung.
* <p>
* Eine Sitzung beginnt mit dem nächsten erfolgreichen
* {@link SchedulerControlUseCase#start()} und endet mit dem zugehörigen
* {@link SchedulerControlUseCase#stop()}. Beim Start einer neuen Sitzung
* werden die Zähler auf null zurückgesetzt; nach dem Stopp bleiben sie
* eingefroren sichtbar, bis der Scheduler erneut gestartet wird.
* <p>
* Übersprungene Dokumente werden in dieser Sitzungsstatistik bewusst nicht
* gezählt, da sie für den Bediener keine neue Verarbeitungsleistung darstellen.
*
* @param successCount Summe aller erfolgreich verarbeiteten Dokumente seit
* Sitzungsstart; nie negativ
* @param failedCount Summe aller fehlgeschlagenen Dokumente seit Sitzungsstart
* (retryable und final zusammengefasst); nie negativ
*/
public record SchedulerSessionTotals(int successCount, int failedCount) {
/**
* Validiert, dass beide Zähler nicht negativ sind.
*
* @throws IllegalArgumentException wenn einer der Zähler kleiner als null ist
*/
public SchedulerSessionTotals {
if (successCount < 0 || failedCount < 0) {
throw new IllegalArgumentException(
"SchedulerSessionTotals counts must not be negative; was: "
+ successCount + "/" + failedCount);
}
}
/**
* Liefert ein neutrales Ausgangsobjekt mit beiden Zählern auf null.
*
* @return Sitzungstotal mit allen Zählern auf null; nie {@code null}
*/
public static SchedulerSessionTotals zero() {
return new SchedulerSessionTotals(0, 0);
}
/**
* Liefert ein neues Sitzungstotal, in dem die übergebenen Werte additiv
* aufgenommen wurden. Das aktuelle Objekt bleibt unverändert.
*
* @param additionalSuccess hinzuzurechnende Erfolgreich-Zahl; muss &ge; 0 sein
* @param additionalFailed hinzuzurechnende Fehler-Zahl; muss &ge; 0 sein
* @return aufaddiertes Sitzungstotal; nie {@code null}
*/
public SchedulerSessionTotals plus(int additionalSuccess, int additionalFailed) {
return new SchedulerSessionTotals(
successCount + additionalSuccess,
failedCount + additionalFailed);
}
}
@@ -0,0 +1,33 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Wird geworfen, wenn der Start des automatischen Schedulers fehlschlägt.
* <p>
* Mögliche Ursachen sind: Fehler beim Erwerb des Konfigurations-Datei-Locks
* oder technische Fehler beim Starten des Scheduler-Adapters.
* <p>
* Diese Ausnahme ist ungeprüft (extends {@link RuntimeException}) und
* wird in der Callchain bis zum GUI-Layer weitergeleitet, der eine
* benutzerfreundliche deutsche Fehlermeldung anzeigt.
*/
public class SchedulerStartException extends RuntimeException {
/**
* Erstellt eine neue {@code SchedulerStartException} mit der angegebenen Nachricht.
*
* @param message benutzerlesbare deutsche Fehlerbeschreibung
*/
public SchedulerStartException(String message) {
super(message);
}
/**
* Erstellt eine neue {@code SchedulerStartException} mit Nachricht und Ursache.
*
* @param message benutzerlesbare deutsche Fehlerbeschreibung
* @param cause technische Ursache
*/
public SchedulerStartException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,72 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Zustandsautomat des automatischen Schedulers.
* <p>
* Beschreibt den aktuellen Lebenszykluszustand des Schedulers und
* steuert, welche Aktionen (Starten, Stoppen, manuelle Läufe)
* in der GUI erlaubt sind.
*/
public enum SchedulerState {
/**
* Scheduler ist gestoppt.
* <p>
* Manuelle Läufe sind erlaubt. Der Scheduler-Start-Button ist
* aktiv, sofern weitere Voraussetzungen erfüllt sind.
*/
STOPPED,
/**
* Scheduler befindet sich im Startvorgang.
* <p>
* Lock-Erwerb und initiale Einrichtung laufen. Manuelle Starts
* sind in diesem Übergangszustand deterministisch gesperrt.
*/
STARTING,
/**
* Scheduler läuft und wartet auf den nächsten Tick.
* <p>
* Kein Batch läuft gerade. Der Countdown bis zum nächsten Tick
* ist sichtbar. Manuelle Läufe sind gesperrt.
*/
RUNNING_IDLE,
/**
* Scheduler läuft und ein Tick verarbeitet gerade einen Batch.
* <p>
* Manuelle Läufe sind gesperrt. Der Stop-Button bleibt aktiv,
* bricht den laufenden Batch jedoch nicht ab.
*/
RUNNING_BATCH_ACTIVE,
/**
* Stopp wurde angefordert, aber der laufende Batch läuft noch zu Ende.
* <p>
* Nach Abschluss des Batches wechselt der Zustand zu {@link #STOPPED}.
* Der Status-Indikator zeigt Gestoppt aktueller Lauf läuft noch".
*/
STOPPING_BATCH_ACTIVE;
/**
* Prüft, ob der Scheduler in diesem Zustand als aktiv gilt.
* <p>
* Als aktiv gelten alle Zustände außer {@link #STOPPED}.
*
* @return {@code true}, wenn der Scheduler nicht gestoppt ist
*/
public boolean isActive() {
return this != STOPPED;
}
/**
* Prüft, ob in diesem Zustand ein Batch verarbeitet wird.
*
* @return {@code true} bei {@link #RUNNING_BATCH_ACTIVE} oder
* {@link #STOPPING_BATCH_ACTIVE}
*/
public boolean isBatchRunning() {
return this == RUNNING_BATCH_ACTIVE || this == STOPPING_BATCH_ACTIVE;
}
}
@@ -0,0 +1,76 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
/**
* Unveränderlicher Snapshot des aktuellen Scheduler-Zustands.
* <p>
* Instanzen dieses Records sind threadsicher, da alle Felder final sind.
* Im {@code DefaultSchedulerControlUseCase} werden Snapshots atomar über
* eine {@code AtomicReference<SchedulerStatus>} ausgetauscht.
* <p>
* Die GUI liest diesen Snapshot regelmäßig über eine zentrale
* Status-Refresh-Timeline (1 Hz) und aktualisiert Scheduler-Tab,
* Batch-Tab und Konfig-Tab entsprechend.
*
* @param state aktueller Lebenszykluszustand des Schedulers
* @param lastRunEndedAt Endzeitpunkt des letzten abgeschlossenen Laufs;
* leer vor dem ersten Lauf
* @param lastRunSummary Zusammenfassung des letzten abgeschlossenen Laufs;
* leer vor dem ersten Lauf
* @param nextTickAt geplanter Zeitpunkt des nächsten Ticks;
* nur befüllt wenn {@code state == RUNNING_IDLE}
* @param lastError letzte aufgetretene deutsche Fehlermeldung;
* wird bei erfolgreichem Lauf gelöscht,
* bei {@code SkippedBusy} unverändert gelassen
* @param sessionTotals aggregierte Zähler seit dem letzten Sitzungsstart;
* leer vor dem allerersten {@code start()}, ab dem
* ersten erfolgreichen Start gefüllt und bei jedem
* weiteren Start auf null zurückgesetzt; nach dem
* Stopp bleibt der eingefrorene Endwert sichtbar
*/
public record SchedulerStatus(
SchedulerState state,
Optional<Instant> lastRunEndedAt,
Optional<RunSummary> lastRunSummary,
Optional<Instant> nextTickAt,
Optional<String> lastError,
Optional<SchedulerSessionTotals> sessionTotals
) {
/**
* Validiert, dass Pflichtfelder und Optional-Felder nicht null sind.
*/
public SchedulerStatus {
if (state == null) {
throw new IllegalArgumentException("state darf nicht null sein");
}
Objects.requireNonNull(lastRunEndedAt, "lastRunEndedAt darf nicht null sein");
Objects.requireNonNull(lastRunSummary, "lastRunSummary darf nicht null sein");
Objects.requireNonNull(nextTickAt, "nextTickAt darf nicht null sein");
Objects.requireNonNull(lastError, "lastError darf nicht null sein");
}
/**
* Erzeugt den initialen Scheduler-Status beim Programmstart.
* <p>
* Zustand ist {@link SchedulerState#STOPPED} und alle optionalen Felder
* sind leer.
*
* @return initialer Scheduler-Status
*/
public static SchedulerStatus initial() {
return new SchedulerStatus(
SchedulerState.STOPPED,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty()
);
}
}
@@ -0,0 +1,49 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.nio.file.Path;
import java.util.Optional;
/**
* Outbound-Port, der die zur Laufzeit aktive SQLite-Datenbankdatei der Anwendung kapselt.
* <p>
* Eigentümer der aktiven DB-Referenz" zur Laufzeit. Der Port erlaubt es, die aktive
* Datenbank über einen In-Memory-Override umzustellen, ohne die Konfigurationsdatei
* (`.properties`) zu verändern. Die GUI nutzt diesen Mechanismus, damit nach dem Anlegen
* einer neuen Datenbank sofort sämtliche DB-Operationen (Verlauf, Reset, Löschen,
* Verarbeitungsläufe) gegen die neue Datei laufen, bevor der Benutzer die Konfiguration
* speichert.
* <p>
* <strong>Architekturgrenze:</strong> Der Port arbeitet ausschließlich mit
* {@link java.nio.file.Path} und kennt keine JDBC- oder SQLite-spezifischen Typen.
* Wie die Implementierung den Override technisch wirksam macht (z. B. durch Ersetzen
* der JDBC-URL beim Verdrahten neuer SQLite-Adapter), ist Adapter-Detail.
*/
public interface ActiveDatabaseContextPort {
/**
* Stellt die aktive SQLite-Datenbankdatei der Anwendung um.
* <p>
* Nach dem Aufruf verwenden alle nachfolgenden DB-Operationen die übergebene Datei
* als aktive Datenbank, sofern keine andere Datei explizit übergeben wird.
*
* @param newDbFile absoluter Pfad der neuen aktiven Datenbankdatei; darf nicht
* {@code null} sein. Die Datei muss zum Zeitpunkt des Aufrufs
* existieren, ein gültiges SQLite-Schema enthalten und lesbar sein
* (Verbindung muss bereits durch den Aufrufer verifiziert worden sein).
* @throws NullPointerException wenn {@code newDbFile} {@code null} ist
*/
void switchActiveDatabase(Path newDbFile);
/**
* Liefert den aktuell aktiven DB-Pfad als Override, sofern einer gesetzt wurde.
* <p>
* Solange kein Override gesetzt wurde, gilt die in der jeweiligen
* {@link de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration}
* konfigurierte Datenbankdatei. Erst nach dem ersten Aufruf von
* {@link #switchActiveDatabase(Path)} liefert diese Methode einen nicht-leeren Wert.
*
* @return das gesetzte Override (nicht-leer) oder {@link Optional#empty()}, wenn die
* konfigurierte Datenbank weiterhin verwendet werden soll; nie {@code null}
*/
Optional<Path> activeDatabaseOverride();
}
@@ -0,0 +1,36 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Funktionales Interface zum Auslösen eines Verarbeitungslaufs.
* <p>
* Dieses Interface entkoppelt den Scheduler-Adapter von konkreten
* Bootstrap- oder GUI-Klassen. Das Bootstrap-Modul erzeugt beim Start
* eine Implementierung als Lambda und übergibt sie beim Starten des
* Schedulers an {@link SchedulerPort#startScheduler(SchedulerConfig, BatchRunTrigger)}.
* <p>
* <strong>Ausführungsmodell:</strong>
* <ul>
* <li>Der Aufruf ist synchron und blockiert bis zum Laufende.</li>
* <li>Ist der Run-Lock nicht verfügbar (anderer Lauf aktiv), kehrt die
* Methode sofort mit {@link BatchRunTriggerResult.SkippedBusy} zurück.</li>
* <li>Tritt ein technischer Fehler auf, liefert die Methode
* {@link BatchRunTriggerResult.Failed} mit deutschen Meldungen.
* Exceptions werden nicht propagiert; Stacktraces werden im Adapter
* geloggt und nicht im Result-Objekt transportiert.</li>
* </ul>
*/
@FunctionalInterface
public interface BatchRunTrigger {
/**
* Löst synchron einen Verarbeitungslauf aus.
* <p>
* Ist der RunLock nicht verfügbar, kehrt diese Methode sofort mit
* {@link BatchRunTriggerResult.SkippedBusy} zurück, ohne einen Lauf
* zu starten. Wird der Lauf gestartet, kehrt die Methode erst nach
* vollständigem Abschluss zurück.
*
* @return Ergebnis des Laufs; nie {@code null}
*/
BatchRunTriggerResult triggerRun();
}
@@ -0,0 +1,87 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.time.Instant;
/**
* Ergebnis eines über {@link BatchRunTrigger#triggerRun()} ausgelösten
* Verarbeitungslaufs.
* <p>
* Die sealed Hierarchie ermöglicht erschöpfendes Pattern-Matching:
* <ul>
* <li>{@link Started} Lauf wurde gestartet und ist abgeschlossen.</li>
* <li>{@link SkippedBusy} Lauf wurde übersprungen, weil bereits ein
* Lauf aktiv war.</li>
* <li>{@link Failed} Lauf ist mit einem technischen Fehler beendet.</li>
* </ul>
* <p>
* {@link Throwable}-Instanzen werden nicht im Result-Objekt transportiert.
* Stacktraces werden im Adapter geloggt; die GUI erhält ausschließlich
* benutzerfreundliche deutsche Meldungen.
*/
public sealed interface BatchRunTriggerResult
permits BatchRunTriggerResult.Started,
BatchRunTriggerResult.SkippedBusy,
BatchRunTriggerResult.Failed {
/**
* Der Lauf wurde erfolgreich gestartet und abgeschlossen.
* <p>
* Das Ergebnis enthält den Endzeitpunkt des Laufs sowie eine
* {@link RunSummary} mit den aggregierten Verarbeitungszählern.
* Ein No-op-Lauf (keine Kandidaten) ist ebenfalls {@code Started}
* und liefert eine {@link RunSummary} mit allen Zählern gleich null.
*
* @param endedAt Zeitpunkt, zu dem der Lauf abgeschlossen wurde
* @param summary Zusammenfassung der Verarbeitungsergebnisse
*/
record Started(Instant endedAt, RunSummary summary)
implements BatchRunTriggerResult {
/**
* Validiert, dass Pflichtfelder nicht null sind.
*/
public Started {
if (endedAt == null) {
throw new IllegalArgumentException("endedAt darf nicht null sein");
}
if (summary == null) {
throw new IllegalArgumentException("summary darf nicht null sein");
}
}
}
/**
* Der Lauf wurde übersprungen, weil bereits ein anderer Lauf aktiv war.
* <p>
* {@link BatchRunTrigger#triggerRun()} kehrt sofort zurück, ohne
* einen neuen Lauf zu starten. Dieser Zustand ist kein Fehler.
*/
record SkippedBusy() implements BatchRunTriggerResult {}
/**
* Der Lauf ist mit einem technischen Fehler beendet worden.
* <p>
* Enthält eine benutzerlesbare deutsche Meldung für die GUI-Anzeige
* sowie eine technische Meldung für Diagnose-Logging. Der zugehörige
* Stacktrace wurde bereits im Adapter auf ERROR geloggt und wird hier
* nicht transportiert.
*
* @param userMessage deutsche, GUI-taugliche Fehlermeldung für den Endanwender
* @param technicalMessage technische Detailmeldung für Diagnose und Logging
*/
record Failed(String userMessage, String technicalMessage)
implements BatchRunTriggerResult {
/**
* Validiert, dass Pflichtfelder nicht null sind.
*/
public Failed {
if (userMessage == null) {
throw new IllegalArgumentException("userMessage darf nicht null sein");
}
if (technicalMessage == null) {
throw new IllegalArgumentException("technicalMessage darf nicht null sein");
}
}
}
}
@@ -0,0 +1,37 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Wird geworfen, wenn der exklusive OS-Lock auf die Konfigurationsdatei
* nicht erworben werden kann.
* <p>
* Typische Ursachen sind: ein externer Prozess hält die Datei bereits
* gesperrt, ein Netzlaufwerk reagiert nicht innerhalb der Deadline,
* oder die Datei ist nicht zugänglich.
* <p>
* Diese Ausnahme ist ungeprüft und wird vom Aufrufer
* ({@link ConfigurationFileLockPort#acquireLock()}) in eine
* benutzerfreundliche GUI-Meldung umgewandelt.
*/
public class ConfigurationFileLockException extends RuntimeException {
/**
* Erstellt eine neue {@code ConfigurationFileLockException} mit der
* angegebenen Nachricht.
*
* @param message deutsche Fehlerbeschreibung
*/
public ConfigurationFileLockException(String message) {
super(message);
}
/**
* Erstellt eine neue {@code ConfigurationFileLockException} mit
* Nachricht und Ursache.
*
* @param message deutsche Fehlerbeschreibung
* @param cause technische Ursache
*/
public ConfigurationFileLockException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,51 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound-Port für den exklusiven OS-Lock auf die Konfigurationsdatei.
* <p>
* Solange der Scheduler läuft oder ein Verarbeitungslauf aktiv ist, hält
* die Anwendung einen exklusiven Lock auf die {@code .properties}-Datei.
* Dieser Lock schützt vor konkurrierenden Schreibzugriffen durch externe
* Prozesse (z.B. Texteditoren).
* <p>
* Der Lock wird als bestmöglicher OS-Level-Schreibschutz betrachtet und
* erhebt keinen Anspruch darauf, alle externen Schreibstrategien zu
* verhindern (z.B. Delete-Rename durch manche Editoren).
* <p>
* Alle Operationen müssen im Worker-Thread ausgeführt werden,
* niemals auf dem JavaFX Application Thread.
*/
public interface ConfigurationFileLockPort {
/**
* Erwirbt den exklusiven OS-Lock auf die Konfigurationsdatei.
* <p>
* Falls die Datei bereits durch einen anderen Prozess gesperrt ist,
* wird der Erwerb mit einer konfigurierbaren Deadline-Schleife
* versucht. Schlägt der Erwerb nach Ablauf der Deadline fehl,
* wird eine {@link ConfigurationFileLockException} geworfen.
* <p>
* Ist der Lock bereits durch diese Instanz gehalten, hat dieser
* Aufruf keine Wirkung.
*
* @throws ConfigurationFileLockException wenn der Lock nicht innerhalb
* der Deadline erworben werden kann
*/
void acquireLock() throws ConfigurationFileLockException;
/**
* Gibt den exklusiven Lock frei.
* <p>
* Ist kein Lock aktiv, hat dieser Aufruf keine Wirkung (idempotent).
* Implementierungen dürfen bei der Freigabe keine geprüfte Ausnahme
* werfen; Fehler werden geloggt und still übergangen.
*/
void releaseLock();
/**
* Prüft, ob der Lock aktuell von dieser Instanz gehalten wird.
*
* @return {@code true}, wenn der Lock aktiv ist
*/
boolean isLocked();
}
@@ -0,0 +1,110 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.nio.file.Path;
import java.util.Objects;
/**
* Outbound-Port zum Anlegen und Initialisieren einer neuen, leeren SQLite-Datenbankdatei
* gegen eine bereits vom Aufrufer reservierte temporäre Zieldatei.
* <p>
* Der Aufrufer (Use-Case) verantwortet die Lebensdauer der temporären Datei: er wählt den
* Pfad, übergibt ihn an diesen Port und führt nach Erfolg den atomaren Move auf den
* endgültigen Zieldateipfad selbst aus. Der Adapter beschränkt sich strikt auf:
* <ol>
* <li>Anlage und Migration der temporären SQLite-Datei auf den neuesten Schema-Stand
* (z. B. via Flyway {@code migrate()});</li>
* <li>technischer Verbindungstest gegen die migrierte Datei (Verbindung öffnen,
* Flyway-History prüfen, einfache Leseabfrage gegen Schema-Metadaten);</li>
* <li>Aufräumen der temporären Datei im Fehlerfall.</li>
* </ol>
* <p>
* <strong>Architekturgrenze:</strong> Provider- und SQLite-spezifische Details
* (JDBC-URL-Schema, DataSource-Konfiguration, Flyway-Konfiguration) bleiben
* ausschließlich im Adapter. Der Port arbeitet mit einem opaken {@link Path} und gibt
* ein versiegeltes Ergebnis zurück.
*/
public interface DatabaseCreationPort {
/**
* Erstellt eine neue, leere SQLite-Datenbankdatei am übergebenen temporären
* Zielpfad und führt eine vollständige Schema-Migration auf den neuesten Stand aus.
* <p>
* Bei Fehlern in einem der Teilschritte (Anlage, Migration, Verbindungstest) wird
* die temporäre Datei zuverlässig wieder entfernt; aufrufende Komponenten müssen
* diesen Aufräumschritt nicht selbst durchführen.
*
* @param tempFile Pfad der zu erstellenden temporären SQLite-Datei; darf nicht
* {@code null} sein. Die Datei darf vor dem Aufruf noch nicht
* existieren; das Elternverzeichnis muss existieren und schreibbar
* sein.
* @return ein versiegeltes Ergebnis: {@link DatabaseCreationResult.Success} bei Erfolg
* oder {@link DatabaseCreationResult.Failure} mit Fehlerklasse und Meldung
* im Fehlerfall; nie {@code null}.
* @throws NullPointerException wenn {@code tempFile} {@code null} ist
*/
DatabaseCreationResult createAndInitialize(Path tempFile);
/**
* Versiegeltes Ergebnis-Interface für {@link DatabaseCreationPort#createAndInitialize(Path)}.
*/
sealed interface DatabaseCreationResult
permits DatabaseCreationResult.Success, DatabaseCreationResult.Failure {
/**
* Erfolgsergebnis. Die temporäre Datei wurde erfolgreich erstellt, migriert
* und durch den Verbindungstest verifiziert. Der Aufrufer kann sie nun atomar
* an den endgültigen Zielpfad verschieben.
*
* @param tempFile der temporäre, erfolgreich migrierte Pfad; nie {@code null}
*/
record Success(Path tempFile) implements DatabaseCreationResult {
/**
* Konstruktor mit Pflichtprüfung.
*
* @param tempFile der temporäre, erfolgreich migrierte Pfad; darf nicht
* {@code null} sein
* @throws NullPointerException wenn {@code tempFile} {@code null} ist
*/
public Success {
Objects.requireNonNull(tempFile, "tempFile darf nicht null sein");
}
}
/**
* Fehlerergebnis. Die temporäre Datei wurde falls bereits angelegt wieder
* entfernt; die aktive DB der Anwendung wurde nicht angetastet.
*
* @param phase die Phase, in der der Fehler auftrat; nie {@code null}
* @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
* @param cause ursächliche Ausnahme; kann {@code null} sein
*/
record Failure(Phase phase, String message, Throwable cause) implements DatabaseCreationResult {
/**
* Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
*
* @param phase die Phase, in der der Fehler auftrat; darf nicht {@code null} sein
* @param message kurze, deutsche Fehlerbeschreibung; darf nicht {@code null} sein
* @param cause ursächliche Ausnahme; kann {@code null} sein
* @throws NullPointerException wenn {@code phase} oder {@code message} {@code null} ist
*/
public Failure {
Objects.requireNonNull(phase, "phase darf nicht null sein");
Objects.requireNonNull(message, "message darf nicht null sein");
}
}
/**
* Phase der Erstellung einer neuen Datenbank, in der ein Fehler auftrat.
*/
enum Phase {
/** Die temporäre Datei konnte nicht erzeugt oder beschrieben werden. */
FILE_CREATION,
/** Die Schema-Migration (Flyway) ist fehlgeschlagen. */
SCHEMA_MIGRATION,
/** Der nachgelagerte Verbindungstest ist fehlgeschlagen. */
CONNECTION_TEST
}
}
}
@@ -18,6 +18,8 @@ public sealed interface PromptSaveResult
PromptSaveResult.WriteFailed, PromptSaveResult.WriteFailed,
PromptSaveResult.TargetDirectoryMissing, PromptSaveResult.TargetDirectoryMissing,
PromptSaveResult.AtomicMoveFailed { PromptSaveResult.AtomicMoveFailed {
String MESSAGE_NOT_NULL = "message must not be null";
/** /**
* Die Prompt-Datei wurde erfolgreich gespeichert. * Die Prompt-Datei wurde erfolgreich gespeichert.
@@ -53,7 +55,7 @@ public sealed interface PromptSaveResult
* @throws NullPointerException wenn {@code message} null ist * @throws NullPointerException wenn {@code message} null ist
*/ */
public WriteFailed { public WriteFailed {
java.util.Objects.requireNonNull(message, "message must not be null"); java.util.Objects.requireNonNull(message, MESSAGE_NOT_NULL);
} }
} }
@@ -71,7 +73,7 @@ public sealed interface PromptSaveResult
* @throws NullPointerException wenn {@code message} null ist * @throws NullPointerException wenn {@code message} null ist
*/ */
public TargetDirectoryMissing { public TargetDirectoryMissing {
java.util.Objects.requireNonNull(message, "message must not be null"); java.util.Objects.requireNonNull(message, MESSAGE_NOT_NULL);
} }
} }
@@ -90,7 +92,7 @@ public sealed interface PromptSaveResult
* @throws NullPointerException wenn {@code message} null ist * @throws NullPointerException wenn {@code message} null ist
*/ */
public AtomicMoveFailed { public AtomicMoveFailed {
java.util.Objects.requireNonNull(message, "message must not be null"); java.util.Objects.requireNonNull(message, MESSAGE_NOT_NULL);
} }
} }
} }
@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Handle für einen erworbenen Run-Lock.
* <p>
* Dieses Interface ermöglicht die Nutzung des Run-Locks in einem
* try-with-resources-Block. Das Schließen des Handles gibt den Lock
* idempotent frei mehrfaches Aufrufen von {@link #close()} ist sicher
* und hat nach dem ersten Aufruf keine Wirkung.
* <p>
* Instanzen dieses Typs werden ausschließlich von
* {@link RunLockPort#tryAcquire()} erzeugt und dürfen nicht
* weitergegeben oder gecacht werden.
*/
public interface RunLockHandle extends AutoCloseable {
/**
* Gibt den Run-Lock frei.
* <p>
* Diese Methode ist idempotent: Mehrfaches Aufrufen hat nach dem
* ersten Aufruf keine weitere Wirkung. Implementierungen dürfen
* keine geprüfte Ausnahme werfen.
*/
@Override
void close();
}
@@ -1,54 +1,76 @@
package de.gecheckt.pdf.umbenenner.application.port.out; package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.Optional;
/** /**
* Outbound port for exclusive run locking. * Outbound-Port für den exklusiven Run-Lock.
* <p> * <p>
* This port abstracts the mechanism for ensuring that only one instance of the PDF Umbenenner * Stellt sicher, dass zu einem Zeitpunkt nur eine Instanz des PDF-Umbenenners
* is executing at any given time. The port defines the contract without prescribing the * einen Verarbeitungslauf ausführt. Der Port abstrahiert den Mechanismus
* implementation (e.g., file-based locks, OS-level locks, distributed locks). * (z.B. dateibasierter Lock) ohne eine konkrete Implementierung vorzuschreiben.
* <p> * <p>
* Responsibilities: * Verantwortlichkeiten:
* <ul> * <ul>
* <li>Guarantee exclusive access to shared resources (SQLite database, target directory)</li> * <li>Exklusiven Zugriff auf gemeinsame Ressourcen (SQLite, Zielordner) sicherstellen</li>
* <li>Prevent concurrent batch runs from overwriting each other's work or causing inconsistencies</li> * <li>Parallele Läufe verhindern</li>
* <li>Allow controlled startup failure if another instance is already running</li> * <li>Kontrollierten Startabbruch ermöglichen, wenn bereits eine Instanz läuft</li>
* </ul> * </ul>
* <p> * <p>
* Lock Lifecycle: * Lock-Lifecycle (blockierender Pfad headless, manueller GUI-Lauf und Scheduler-Tick):
* <ul> * <ol>
* <li>Acquire the lock at batch startup (before any processing)</li> * <li>Lock beim Laufstart erwerben ({@link #acquire()})</li>
* <li>Hold the lock for the entire batch run</li> * <li>Lock für die gesamte Dauer des Laufs halten</li>
* <li>Release the lock cleanly at batch end (even on failure, if possible)</li> * <li>Lock am Laufende freigeben ({@link #release()}), auch bei Fehler</li>
* </ul> * </ol>
* * Der Scheduler-Tick verwendet dieselbe blockierende Methode {@link #acquire()} wie
* der manuelle Laufpfad. {@link BatchRunProcessingUseCase#execute execute()} ruft
* {@link #acquire()} intern auf; das Ergebnis {@code LOCK_UNAVAILABLE} signalisiert
* dem Aufrufer, dass ein paralleler Lauf aktiv ist.
* {@link #tryAcquire()} ist für Aufrufer vorgesehen, die außerhalb des Use-Case
* einen schnellen, nicht-blockierenden Lock-Versuch benötigen.
*/ */
public interface RunLockPort { public interface RunLockPort {
/** /**
* Acquires an exclusive lock for the batch run. * Erwirbt den exklusiven Run-Lock (blockierend).
* <p> * <p>
* This method blocks or throws an exception if the lock cannot be acquired * Wenn der Lock nicht erworben werden kann (z.B. weil eine andere
* (e.g., another instance already holds it). The behavior depends on the implementation. * Instanz ihn bereits hält), wird eine {@link RunLockUnavailableException}
* <p> * geworfen. Bei normalem Rücksprung hält der Aufrufer den Lock und muss
* If this method returns normally, the caller holds the lock and must ensure * {@link #release()} in einem {@code finally}-Block aufrufen.
* {@link #release()} is called to free it, typically in a finally block.
* *
* @throws RunLockUnavailableException if the lock cannot be acquired * @throws RunLockUnavailableException wenn der Lock nicht erworben werden kann
* (e.g., another instance already holds it or system error prevents acquiring) * @throws RuntimeException bei anderen kritischen Fehlern
* @throws RuntimeException for other critical lock-related failures
*/ */
void acquire(); void acquire();
/** /**
* Releases the exclusive lock held by this batch run. * Gibt den exklusiven Run-Lock frei.
* <p> * <p>
* This method is called after batch processing completes (successfully or not) * Wird nach Abschluss des Laufs (erfolgreich oder fehlerhaft) aufgerufen.
* to allow other instances to run. * Implementierungen sollen keinen Fehler werfen, wenn der Lock bereits
* <p> * freigegeben wurde oder gar nicht gehalten wird.
* Implementations should handle the case where release is called multiple times
* or when no lock is currently held, avoiding exceptions if possible.
* *
* @throws RuntimeException if lock release fails critically * @throws RuntimeException wenn die Freigabe kritisch fehlschlägt
*/ */
void release(); void release();
/**
* Versucht nicht-blockierend, den Run-Lock zu erwerben.
* <p>
* Gibt ein {@link RunLockHandle} zurück, wenn der Lock erfolgreich
* erworben wurde. Das Handle kann in einem try-with-resources-Block
* verwendet werden; {@link RunLockHandle#close()} gibt den Lock
* idempotent frei.
* <p>
* Ist der Lock bereits durch eine andere Instanz gehalten, wird sofort
* {@link Optional#empty()} zurückgegeben ohne zu warten oder zu queuen.
* Diese Methode ist race-condition-sicher und frei von check-then-act-Mustern.
*
* @return Handle mit dem erworbenen Lock, oder {@link Optional#empty()}
* wenn der Lock nicht verfügbar ist
* @throws RuntimeException bei kritischen technischen Fehlern beim
* Lock-Versuch (nicht bei normaler Nicht-Verfügbarkeit)
*/
Optional<RunLockHandle> tryAcquire();
} }
@@ -0,0 +1,37 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Zusammenfassung eines abgeschlossenen Verarbeitungslaufs.
* <p>
* Enthält die aggregierten Zähler für erfolgreich verarbeitete,
* fehlgeschlagene und übersprungene Dokumente. Ein No-op-Lauf
* bei dem keine Kandidaten im Quellordner gefunden wurden wird
* durch {@code RunSummary(0, 0, 0)} repräsentiert.
*
* @param successCount Anzahl der in diesem Lauf erfolgreich verarbeiteten Dokumente
* @param failedCount Anzahl der in diesem Lauf fehlgeschlagenen Dokumente
* (retryable und final zusammengefasst)
* @param skippedCount Anzahl der in diesem Lauf übersprungenen Dokumente
* (bereits verarbeitet oder dauerhaft fehlgeschlagen)
*/
public record RunSummary(int successCount, int failedCount, int skippedCount) {
/**
* Prüft, ob dieser Lauf ein No-op-Lauf war, d.h. keine Dokumente
* verarbeitet, fehlgeschlagen oder übersprungen wurden.
*
* @return {@code true}, wenn alle Zähler null sind
*/
public boolean isNoOp() {
return successCount == 0 && failedCount == 0 && skippedCount == 0;
}
/**
* Erzeugt eine {@code RunSummary} für einen No-op-Lauf.
*
* @return {@code RunSummary} mit allen Zählern gleich null
*/
public static RunSummary noOp() {
return new RunSummary(0, 0, 0);
}
}
@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Betriebskonfiguration für den automatischen Scheduler.
* <p>
* Enthält ausschließlich die für den Scheduler-Betrieb relevanten
* Laufzeitparameter. Die Konfiguration wird beim Start des Schedulers
* an {@link SchedulerPort#startScheduler(SchedulerConfig, BatchRunTrigger)}
* übergeben und bleibt für die Dauer des Scheduler-Betriebs unverändert.
*
* @param intervalSeconds Wartezeit in Sekunden zwischen dem Ende eines
* Laufs und dem Beginn des nächsten Ticks;
* muss mindestens 30 betragen
*/
public record SchedulerConfig(int intervalSeconds) {
/**
* Validiert, dass das Intervall mindestens 30 Sekunden beträgt.
*/
public SchedulerConfig {
if (intervalSeconds < 30) {
throw new IllegalArgumentException(
"Scheduler-Intervall muss mindestens 30 Sekunden betragen, war: " + intervalSeconds);
}
}
}
@@ -0,0 +1,42 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound-Port zur Steuerung des technischen Scheduler-Mechanismus.
* <p>
* Kapselt die Infrastruktur für das periodische Polling (z.B. einen
* {@code ScheduledExecutorService}). Die fachliche Scheduler-Steuerung
* liegt im Use Case {@code SchedulerControlUseCase}; dieser Port
* delegiert ausschließlich den technischen Lifecycle-Start und -Stop.
* <p>
* Abhängigkeitsrichtung: Application Adapter (hexagonal outbound).
*/
public interface SchedulerPort {
/**
* Startet den periodischen Scheduler-Mechanismus.
* <p>
* Der erste Tick startet sofort (Initial Delay 0). Nachfolgende Ticks
* starten jeweils {@link SchedulerConfig#intervalSeconds()} Sekunden
* nach dem Ende des vorigen Ticks ({@code scheduleWithFixedDelay}).
* <p>
* Der bereitgestellte {@link BatchRunTrigger} wird bei jedem Tick
* synchron aufgerufen. Der Scheduler-Adapter darf keine weiteren
* Entscheidungen treffen er ruft {@code trigger.triggerRun()} auf
* und aktualisiert den Zustand anhand des Ergebnisses.
*
* @param config Betriebskonfiguration mit Intervall in Sekunden
* @param trigger Auslöser für den Verarbeitungslauf pro Tick
* @throws RuntimeException wenn der Scheduler-Mechanismus nicht
* gestartet werden kann
*/
void startScheduler(SchedulerConfig config, BatchRunTrigger trigger);
/**
* Stoppt den periodischen Scheduler-Mechanismus.
* <p>
* Laufende Ticks werden nicht abgebrochen; es werden lediglich keine
* weiteren Ticks geplant. Ist der Scheduler bereits gestoppt, hat
* dieser Aufruf keine Wirkung (idempotent).
*/
void stopScheduler();
}
@@ -0,0 +1,28 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Persistierte Scheduler-Einstellungen aus der {@code .properties}-Datei.
* <p>
* Dieses DTO repräsentiert die Scheduler-Property
* {@code scheduler.interval.seconds}, wie sie aus der Konfigurationsdatei
* gelesen wird. Es wird von {@link SchedulerSettingsPort#loadSettings()}
* zurückgegeben und dient als Eingabe für die Scheduler-Tab-Anzeige.
*
* @param intervalSeconds konfigurierte Wartezeit in Sekunden zwischen
* Läufen; entspricht dem gelesenen Rohwert
* ohne weitere Validierung
*/
public record SchedulerSettings(int intervalSeconds) {
/** Standardwert für {@code scheduler.interval.seconds}, wenn der Key fehlt oder leer ist. */
public static final int DEFAULT_INTERVAL_SECONDS = 180;
/**
* Erzeugt eine {@code SchedulerSettings}-Instanz mit Standardwerten.
*
* @return Instanz mit {@code intervalSeconds=180}
*/
public static SchedulerSettings defaults() {
return new SchedulerSettings(DEFAULT_INTERVAL_SECONDS);
}
}
@@ -0,0 +1,45 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound-Port zum Lesen und Schreiben der Scheduler-Einstellungen
* in der {@code .properties}-Konfigurationsdatei.
* <p>
* Schreiboperationen aktualisieren ausschließlich den Scheduler-Key
* {@code scheduler.interval.seconds}. Alle übrigen Zeilen, Kommentare
* und unbekannten Properties bleiben unverändert erhalten.
* <p>
* Schreibvorgänge sind atomar: Sie erfolgen über eine temporäre Datei,
* die erst nach vollständigem Schreiben an den Zielort verschoben wird.
* Bei einem Fehler bleibt die Originaldatei unverändert.
* <p>
* Die Implementierung teilt sich einen {@code FileChannel} mit dem
* {@link ConfigurationFileLockPort}-Adapter, damit Settings auch
* während eines aktiven OS-Locks geschrieben werden können.
*/
public interface SchedulerSettingsPort {
/**
* Liest die aktuellen Scheduler-Einstellungen aus der
* Konfigurationsdatei.
* <p>
* Fehlt ein Key oder ist er leer, wird der jeweilige Standardwert
* zurückgegeben (siehe {@link SchedulerSettings#defaults()}).
* Ungültige Werte (z.B. nicht-numerisches Intervall) werden als
* Fehler in {@code SchedulerSettings} signalisiert und führen nicht
* zu einer Exception in diesem Port.
*
* @return gelesene Scheduler-Einstellungen; nie {@code null}
*/
SchedulerSettings loadSettings();
/**
* Schreibt den Wert von {@code scheduler.interval.seconds} in die
* Konfigurationsdatei.
* <p>
* Alle übrigen Inhalte der Datei bleiben unverändert.
*
* @param seconds neues Intervall in Sekunden; muss mindestens 30 betragen
* @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt
*/
void saveIntervalSeconds(int seconds) throws SchedulerSettingsWriteException;
}
@@ -0,0 +1,37 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Wird geworfen, wenn das Schreiben der Scheduler-Einstellungen in die
* {@code .properties}-Datei fehlschlägt.
* <p>
* Der Schreibvorgang ist atomar (über eine temporäre Datei), sodass die
* Konfigurationsdatei bei einem Fehler nicht in einem korrupten Zustand
* hinterlassen wird. Diese Ausnahme signalisiert, dass weder der neue
* noch ein partieller Stand geschrieben wurde.
* <p>
* Ursachen können sein: fehlende Schreibrechte, Netzlaufwerksfehler,
* Festplatte voll oder ein aktiver exklusiver Lock durch einen anderen Prozess.
*/
public class SchedulerSettingsWriteException extends RuntimeException {
/**
* Erstellt eine neue {@code SchedulerSettingsWriteException} mit der
* angegebenen Nachricht.
*
* @param message deutsche Fehlerbeschreibung
*/
public SchedulerSettingsWriteException(String message) {
super(message);
}
/**
* Erstellt eine neue {@code SchedulerSettingsWriteException} mit
* Nachricht und Ursache.
*
* @param message deutsche Fehlerbeschreibung
* @param cause technische Ursache
*/
public SchedulerSettingsWriteException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -32,7 +32,7 @@ public record EffectiveApiKeyDescriptor(
*/ */
public EffectiveApiKeyDescriptor { public EffectiveApiKeyDescriptor {
Objects.requireNonNull(origin, "origin must not be null"); Objects.requireNonNull(origin, "origin must not be null");
envVarName = envVarName == null ? Optional.empty() : envVarName; envVarName = Objects.requireNonNullElse(envVarName, Optional.empty());
} }
/** /**
@@ -45,7 +45,7 @@ public record ModelCatalogRequest(
if (timeoutSeconds <= 0) { if (timeoutSeconds <= 0) {
throw new IllegalArgumentException("timeoutSeconds must be positive, was: " + timeoutSeconds); throw new IllegalArgumentException("timeoutSeconds must be positive, was: " + timeoutSeconds);
} }
baseUrl = baseUrl == null ? Optional.empty() : baseUrl; baseUrl = Objects.requireNonNullElse(baseUrl, Optional.empty());
apiKey = apiKey == null ? Optional.empty() : apiKey; apiKey = Objects.requireNonNullElse(apiKey, Optional.empty());
} }
} }
@@ -29,6 +29,8 @@ public sealed interface ModelCatalogResult
ModelCatalogResult.EmptyList, ModelCatalogResult.EmptyList,
ModelCatalogResult.IncompleteConfiguration, ModelCatalogResult.IncompleteConfiguration,
ModelCatalogResult.TechnicalFailure { ModelCatalogResult.TechnicalFailure {
String PROVIDER_ID_NOT_NULL = "providerIdentifier must not be null";
/** /**
* The provider returned a non-empty list of available model identifiers. * The provider returned a non-empty list of available model identifiers.
@@ -55,7 +57,7 @@ public sealed interface ModelCatalogResult
* @throws IllegalArgumentException if {@code models} is empty * @throws IllegalArgumentException if {@code models} is empty
*/ */
public Success { public Success {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null"); Objects.requireNonNull(providerIdentifier, PROVIDER_ID_NOT_NULL);
Objects.requireNonNull(models, "models must not be null"); Objects.requireNonNull(models, "models must not be null");
Objects.requireNonNull(loadedAt, "loadedAt must not be null"); Objects.requireNonNull(loadedAt, "loadedAt must not be null");
if (models.isEmpty()) { if (models.isEmpty()) {
@@ -88,7 +90,7 @@ public sealed interface ModelCatalogResult
* @throws NullPointerException if any parameter is {@code null} * @throws NullPointerException if any parameter is {@code null}
*/ */
public EmptyList { public EmptyList {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null"); Objects.requireNonNull(providerIdentifier, PROVIDER_ID_NOT_NULL);
Objects.requireNonNull(loadedAt, "loadedAt must not be null"); Objects.requireNonNull(loadedAt, "loadedAt must not be null");
} }
} }
@@ -118,7 +120,7 @@ public sealed interface ModelCatalogResult
* @throws NullPointerException if any parameter is {@code null} * @throws NullPointerException if any parameter is {@code null}
*/ */
public IncompleteConfiguration { public IncompleteConfiguration {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null"); Objects.requireNonNull(providerIdentifier, PROVIDER_ID_NOT_NULL);
Objects.requireNonNull(missingReason, "missingReason must not be null"); Objects.requireNonNull(missingReason, "missingReason must not be null");
} }
} }
@@ -153,7 +155,7 @@ public sealed interface ModelCatalogResult
* @throws NullPointerException if any parameter is {@code null} * @throws NullPointerException if any parameter is {@code null}
*/ */
public TechnicalFailure { public TechnicalFailure {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null"); Objects.requireNonNull(providerIdentifier, PROVIDER_ID_NOT_NULL);
Objects.requireNonNull(errorCategory, "errorCategory must not be null"); Objects.requireNonNull(errorCategory, "errorCategory must not be null");
Objects.requireNonNull(errorDetail, "errorDetail must not be null"); Objects.requireNonNull(errorDetail, "errorDetail must not be null");
} }
@@ -38,6 +38,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
* of {@link AiResponseValidator}. * of {@link AiResponseValidator}.
*/ */
public final class AiResponseParser { public final class AiResponseParser {
private static final String JSON_KEY_TITLE = "title";
private static final String JSON_KEY_REASONING = "reasoning";
private AiResponseParser() { private AiResponseParser() {
// Static utility no instances // Static utility no instances
@@ -81,19 +85,19 @@ public final class AiResponseParser {
} }
// Validate mandatory field: title // Validate mandatory field: title
if (!json.has("title") || json.isNull("title")) { if (!json.has(JSON_KEY_TITLE) || json.isNull(JSON_KEY_TITLE)) {
return new AiResponseParsingFailure("MISSING_TITLE", "AI response missing mandatory field 'title'"); return new AiResponseParsingFailure("MISSING_TITLE", "AI response missing mandatory field 'title'");
} }
String title = json.getString("title"); String title = json.getString(JSON_KEY_TITLE);
if (title.isBlank()) { if (title.isBlank()) {
return new AiResponseParsingFailure("BLANK_TITLE", "AI response field 'title' is blank"); return new AiResponseParsingFailure("BLANK_TITLE", "AI response field 'title' is blank");
} }
// Validate mandatory field: reasoning // Validate mandatory field: reasoning
if (!json.has("reasoning") || json.isNull("reasoning")) { if (!json.has(JSON_KEY_REASONING) || json.isNull(JSON_KEY_REASONING)) {
return new AiResponseParsingFailure("MISSING_REASONING", "AI response missing mandatory field 'reasoning'"); return new AiResponseParsingFailure("MISSING_REASONING", "AI response missing mandatory field 'reasoning'");
} }
String reasoning = json.getString("reasoning"); String reasoning = json.getString(JSON_KEY_REASONING);
// Optional field: date // Optional field: date
String dateString = null; String dateString = null;
@@ -0,0 +1,307 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
/**
* Standardimplementierung des {@link CreateNewDatabaseUseCase}.
* <p>
* Orchestriert den vollständigen Anlage- und Wechselvorgang einer neuen, leeren
* SQLite-Datenbankdatei und delegiert die technischen Teilschritte an die Ports
* {@link DatabaseCreationPort} und {@link ActiveDatabaseContextPort}. Der Adapter
* darunter (z. B. SQLite/Flyway) bleibt für den Use-Case unsichtbar.
*
* <h2>Atomarität</h2>
* Aus Anwendungssicht ist der Wechsel atomar:
* <ul>
* <li>Bei einem Fehler in einem der Schritte wird die temporäre Datei zuverlässig
* entfernt; die aktive Datenbank bleibt unverändert in Betrieb.</li>
* <li>Erst nach erfolgreichem Verbindungstest wird die temporäre Datei via
* {@link StandardCopyOption#ATOMIC_MOVE} mit
* {@link StandardCopyOption#REPLACE_EXISTING} an den endgültigen Zielpfad
* verschoben. Bei nicht unterstützter Kombination wird der Vorgang mit
* klarer Fehlermeldung abgebrochen kein stiller Fallback.</li>
* </ul>
*
* <h2>Pfad-Sicherheitsprüfung</h2>
* Aktive DB und Zielpfad werden über {@link Path#toRealPath(java.nio.file.LinkOption...)}
* normalisiert verglichen. Für noch nicht existierende Dateien wird das Elternverzeichnis
* real aufgelöst und der Dateiname normalisiert verglichen. Auf Windows erfolgt der
* Vergleich case-insensitive.
*/
public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase {
private static final Logger LOG = LogManager.getLogger(DefaultCreateNewDatabaseUseCase.class);
private static final String OS_NAME = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
private final DatabaseCreationPort databaseCreationPort;
private final ActiveDatabaseContextPort activeDatabaseContextPort;
private final ActiveDatabasePathSupplier activeDatabasePathSupplier;
/**
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
* <p>
* Diese Indirektion erlaubt es dem Bootstrap, sowohl den
* {@link ActiveDatabaseContextPort}-Override als auch den Wert aus der geladenen
* Konfigurationsdatei zu berücksichtigen, ohne dass der Use-Case Konfigurationstypen
* kennen muss.
*/
@FunctionalInterface
public interface ActiveDatabasePathSupplier {
/**
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
*
* @return den absoluten Pfad der aktiven Datei; nie {@code null}
*/
Path get();
}
/**
* Erzeugt den Use-Case mit den drei erforderlichen Ports/Lieferanten.
*
* @param databaseCreationPort Port zum Anlegen und Initialisieren der Temp-Datei;
* darf nicht {@code null} sein
* @param activeDatabaseContextPort Port zum Umstellen der aktiven DB-Referenz;
* darf nicht {@code null} sein
* @param activeDatabasePathSupplier Lieferant für den Pfad der aktuell aktiven
* SQLite-Datei; darf nicht {@code null} sein
* @throws NullPointerException wenn ein Parameter {@code null} ist
*/
public DefaultCreateNewDatabaseUseCase(DatabaseCreationPort databaseCreationPort,
ActiveDatabaseContextPort activeDatabaseContextPort,
ActiveDatabasePathSupplier activeDatabasePathSupplier) {
this.databaseCreationPort = Objects.requireNonNull(databaseCreationPort,
"databaseCreationPort darf nicht null sein");
this.activeDatabaseContextPort = Objects.requireNonNull(activeDatabaseContextPort,
"activeDatabaseContextPort darf nicht null sein");
this.activeDatabasePathSupplier = Objects.requireNonNull(activeDatabasePathSupplier,
"activeDatabasePathSupplier darf nicht null sein");
}
/**
* Orchestriert den vollständigen Anlage- und Wechselvorgang.
*
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null} sein
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie {@code null}
*/
@Override
public CreateNewDatabaseResult createNewDatabase(Path targetFile) {
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
Path absoluteTarget = targetFile.toAbsolutePath().normalize();
LOG.info("Neue Datenbank anlegen: angeforderter Zielpfad = {}", absoluteTarget);
// Schritt 1: Pfad-Sicherheitsprüfung
Path activeDb = activeDatabasePathSupplier.get();
if (activeDb == null) {
LOG.error("Aktiver Datenbankpfad ist nicht ermittelbar Anlage abgebrochen.");
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
"Aktiver Datenbankpfad konnte nicht ermittelt werden.",
null);
}
Path absoluteActive = activeDb.toAbsolutePath().normalize();
boolean sameFile;
try {
sameFile = isSameFile(absoluteActive, absoluteTarget);
} catch (IOException e) {
LOG.error("Pfad-Sicherheitsprüfung fehlgeschlagen: {}", e.getMessage(), e);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
"Pfad-Sicherheitsprüfung fehlgeschlagen: " + e.getMessage(),
e);
}
if (sameFile) {
LOG.warn("Anlage abgelehnt: Zielpfad entspricht der aktuell aktiven Datenbank: {}",
absoluteTarget);
return new CreateNewDatabaseResult.SameAsActiveDatabase(absoluteTarget);
}
// Schritt 2: Temp-Datei im Zielverzeichnis vorbereiten
Path parent = absoluteTarget.getParent();
if (parent == null) {
LOG.error("Zielpfad besitzt kein Elternverzeichnis: {}", absoluteTarget);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
"Zielpfad besitzt kein Elternverzeichnis.",
null);
}
try {
if (!Files.isDirectory(parent)) {
Files.createDirectories(parent);
}
} catch (IOException e) {
LOG.error("Zielverzeichnis konnte nicht angelegt werden: {}", parent, e);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.FILE_CREATION,
"Zielverzeichnis konnte nicht angelegt werden: " + e.getMessage(),
e);
}
Path tempFile = parent.resolve(absoluteTarget.getFileName().toString()
+ ".new-" + UUID.randomUUID() + ".tmp");
// Schritt 3: Adapter führt Anlage + Schema-Migration + Verbindungstest aus
DatabaseCreationPort.DatabaseCreationResult creationResult;
try {
creationResult = databaseCreationPort.createAndInitialize(tempFile);
} catch (RuntimeException e) {
LOG.error("Unerwarteter Fehler beim Anlegen der temporären Datenbank: {}",
e.getMessage(), e);
deleteTempQuietly(tempFile);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.FILE_CREATION,
"Unerwarteter Fehler beim Anlegen der temporären Datenbank: " + e.getMessage(),
e);
}
if (creationResult instanceof DatabaseCreationPort.DatabaseCreationResult.Failure failure) {
CreateNewDatabaseResult.Phase phase = mapPhase(failure.phase());
LOG.error("Anlage der neuen Datenbank fehlgeschlagen ({}): {}",
failure.phase(), failure.message());
deleteTempQuietly(tempFile);
return new CreateNewDatabaseResult.CreationFailed(phase, failure.message(),
failure.cause());
}
// Schritt 4: atomarer Move auf Zielpfad
try {
Files.move(tempFile, absoluteTarget,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
LOG.error("Atomarer Move nicht unterstützt für Zielpfad {}: {}",
absoluteTarget, e.getMessage(), e);
deleteTempQuietly(tempFile);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
"Atomarer Move (ATOMIC_MOVE + REPLACE_EXISTING) wird vom Dateisystem nicht "
+ "unterstützt. Kein nicht-atomarer Fallback. Ziel: "
+ absoluteTarget,
e);
} catch (IOException e) {
LOG.error("Atomarer Move fehlgeschlagen: {}", e.getMessage(), e);
deleteTempQuietly(tempFile);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
"Verschieben der temporären Datenbank zum Zielpfad fehlgeschlagen: "
+ e.getMessage(),
e);
}
// Schritt 5: Aktive DB-Referenz umstellen
try {
activeDatabaseContextPort.switchActiveDatabase(absoluteTarget);
} catch (RuntimeException e) {
LOG.error("Umstellen der aktiven DB-Referenz fehlgeschlagen: {}", e.getMessage(), e);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.CONTEXT_SWITCH,
"Aktive DB-Referenz konnte nicht umgestellt werden: " + e.getMessage(),
e);
}
LOG.info("Neue Datenbank erfolgreich angelegt und aktiviert: {}", absoluteTarget);
return new CreateNewDatabaseResult.Success(absoluteTarget);
}
/**
* Vergleicht zwei Datenbankpfade mit Berücksichtigung von Symlinks und
* (auf Windows) Case-Insensitivität.
* <p>
* Existieren beide Dateien, wird {@code Files.isSameFile(...)} verwendet.
* Existiert eine der beiden Dateien (typischerweise das Ziel) noch nicht, werden
* Elternverzeichnisse via {@link Path#toRealPath(java.nio.file.LinkOption...)}
* aufgelöst und mit den Dateinamen kombiniert verglichen. Auf Windows erfolgt der
* abschließende String-Vergleich case-insensitive.
*
* @param a Pfad A; darf nicht {@code null} sein
* @param b Pfad B; darf nicht {@code null} sein
* @return {@code true}, wenn beide Pfade auf dieselbe Datei zeigen
* @throws IOException bei Auflösungsfehlern existierender Pfadbestandteile
*/
static boolean isSameFile(Path a, Path b) throws IOException {
Objects.requireNonNull(a, "a darf nicht null sein");
Objects.requireNonNull(b, "b darf nicht null sein");
if (Files.exists(a) && Files.exists(b)) {
return Files.isSameFile(a, b);
}
Path realA = resolveBest(a);
Path realB = resolveBest(b);
if (isWindows()) {
return realA.toString().equalsIgnoreCase(realB.toString());
}
return realA.equals(realB);
}
/**
* Löst die existierenden Bestandteile eines Pfades soweit möglich real auf und
* normalisiert den Rest. Wird verwendet, wenn die Datei selbst noch nicht existiert.
*
* @param path der zu normalisierende Pfad
* @return ein bestmöglich aufgelöster, normalisierter Pfad
* @throws IOException bei {@link Path#toRealPath(java.nio.file.LinkOption...)}-Fehlern
*/
private static Path resolveBest(Path path) throws IOException {
if (Files.exists(path)) {
return path.toRealPath();
}
Path parent = path.toAbsolutePath().normalize().getParent();
Path fileName = path.getFileName();
if (parent != null && Files.exists(parent)) {
return parent.toRealPath().resolve(fileName == null ? "" : fileName.toString());
}
return path.toAbsolutePath().normalize();
}
/**
* Liefert {@code true}, wenn die laufende JVM auf Windows läuft.
*
* @return {@code true}, wenn Windows; sonst {@code false}
*/
private static boolean isWindows() {
return OS_NAME.contains("win");
}
private void deleteTempQuietly(Path tempFile) {
if (tempFile == null) {
return;
}
try {
Files.deleteIfExists(tempFile);
} catch (IOException e) {
LOG.warn("Temporäre Datenbankdatei konnte nicht gelöscht werden: {} {}",
tempFile, e.getMessage());
}
}
private static CreateNewDatabaseResult.Phase mapPhase(
DatabaseCreationPort.DatabaseCreationResult.Phase phase) {
return switch (phase) {
case FILE_CREATION -> CreateNewDatabaseResult.Phase.FILE_CREATION;
case SCHEMA_MIGRATION -> CreateNewDatabaseResult.Phase.SCHEMA_MIGRATION;
case CONNECTION_TEST -> CreateNewDatabaseResult.Phase.CONNECTION_TEST;
};
}
/**
* Liefert das gesetzte Override (sofern vorhanden), für Diagnose- und Logging-Zwecke.
* Nicht Teil der öffentlichen Use-Case-API.
*
* @return das Override aus dem {@link ActiveDatabaseContextPort}; nie {@code null}
*/
Optional<Path> currentOverride() {
return activeDatabaseContextPort.activeDatabaseOverride();
}
}
@@ -70,6 +70,17 @@ public class DefaultManualFileCopyUseCase implements ManualFileCopyUseCase {
private final ClockPort clock; private final ClockPort clock;
private final ProcessingLogger logger; private final ProcessingLogger logger;
/** Ergebnis der Dokument-Stammsatz-Auflösung: entweder ein Record oder ein Fehler. */
private record RecordLookupOutcome(DocumentRecord record, ManualFileCopyResult failure) {
boolean hasFailed() { return failure != null; }
}
/** Ergebnis der Zieldateinamen-Auflösung: entweder Name + No-Op-Flag oder ein Fehler. */
private record FilenameLookupOutcome(String appliedFileName, boolean noOpIdentical,
ManualFileCopyResult failure) {
boolean hasFailed() { return failure != null; }
}
/** /**
* Erstellt den Use-Case mit allen erforderlichen Ports. * Erstellt den Use-Case mit allen erforderlichen Ports.
* *
@@ -127,86 +138,144 @@ public class DefaultManualFileCopyUseCase implements ManualFileCopyUseCase {
logger.info("Manuelle Dateikopie angefordert: Fingerprint={}, Zielname={}", logger.info("Manuelle Dateikopie angefordert: Fingerprint={}, Zielname={}",
fingerprint.sha256Hex(), desiredFullName); fingerprint.sha256Hex(), desiredFullName);
// Schritt 1: Dokument-Stammsatz laden und Zustand prüfen RecordLookupOutcome recordOutcome = loadAndValidateRecord(fingerprint);
if (recordOutcome.hasFailed()) {
return recordOutcome.failure();
}
DocumentRecord record = recordOutcome.record();
FilenameLookupOutcome filenameOutcome = resolveTargetFilename(fingerprint, desiredFullName);
if (filenameOutcome.hasFailed()) {
return filenameOutcome.failure();
}
String appliedFileName = filenameOutcome.appliedFileName();
boolean noOpIdentical = filenameOutcome.noOpIdentical();
if (!noOpIdentical) {
ManualFileCopyResult copyFailure = performFileCopy(fingerprint, record, appliedFileName);
if (copyFailure != null) {
return copyFailure;
}
}
return persistAndBuildResult(fingerprint, record, appliedFileName, noOpIdentical, desiredFullName);
}
/**
* Lädt den Dokument-Stammsatz und prüft, ob der aktuelle Status eine manuelle
* Kopie erlaubt.
*
* @param fingerprint der Fingerprint des Dokuments
* @return Outcome mit geladenem Record oder mit einem Fehler-Ergebnis
*/
private RecordLookupOutcome loadAndValidateRecord(DocumentFingerprint fingerprint) {
DocumentRecordLookupResult lookupResult = repository.findByFingerprint(fingerprint); DocumentRecordLookupResult lookupResult = repository.findByFingerprint(fingerprint);
DocumentRecord record;
if (lookupResult instanceof DocumentTerminalFinalFailure terminalFailure) { if (lookupResult instanceof DocumentTerminalFinalFailure terminalFailure) {
record = terminalFailure.record(); return new RecordLookupOutcome(terminalFailure.record(), null);
} else if (lookupResult instanceof DocumentKnownProcessable known) { } else if (lookupResult instanceof DocumentKnownProcessable known) {
record = known.record(); DocumentRecord record = known.record();
ProcessingStatus status = record.overallStatus(); if (record.overallStatus() == ProcessingStatus.SUCCESS) {
if (status == ProcessingStatus.SUCCESS) {
// Defensiv: SUCCESS sollte über DocumentTerminalSuccess auflösen, nicht hier.
logger.warn("Manuelle Dateikopie verweigert: Dokument bereits SUCCESS. Fingerprint={}", logger.warn("Manuelle Dateikopie verweigert: Dokument bereits SUCCESS. Fingerprint={}",
fingerprint.sha256Hex()); fingerprint.sha256Hex());
return new ManualFileCopyInvalidState( return new RecordLookupOutcome(null, new ManualFileCopyInvalidState(
"Dokument ist bereits erfolgreich verarbeitet. Bitte die Umbenennung der " "Dokument ist bereits erfolgreich verarbeitet. Bitte die Umbenennung der "
+ "Zieldatei verwenden."); + "Zieldatei verwenden."));
} }
return new RecordLookupOutcome(record, null);
} else if (lookupResult instanceof DocumentTerminalSuccess) { } else if (lookupResult instanceof DocumentTerminalSuccess) {
logger.warn("Manuelle Dateikopie verweigert: Dokument bereits SUCCESS. Fingerprint={}", logger.warn("Manuelle Dateikopie verweigert: Dokument bereits SUCCESS. Fingerprint={}",
fingerprint.sha256Hex()); fingerprint.sha256Hex());
return new ManualFileCopyInvalidState( return new RecordLookupOutcome(null, new ManualFileCopyInvalidState(
"Dokument ist bereits erfolgreich verarbeitet. Bitte die Umbenennung der " "Dokument ist bereits erfolgreich verarbeitet. Bitte die Umbenennung der "
+ "Zieldatei verwenden."); + "Zieldatei verwenden."));
} else if (lookupResult instanceof DocumentUnknown) { } else if (lookupResult instanceof DocumentUnknown) {
logger.warn("Manuelle Dateikopie verweigert: Dokument unbekannt. Fingerprint={}", logger.warn("Manuelle Dateikopie verweigert: Dokument unbekannt. Fingerprint={}",
fingerprint.sha256Hex()); fingerprint.sha256Hex());
return new ManualFileCopyDocumentNotFound( return new RecordLookupOutcome(null, new ManualFileCopyDocumentNotFound(
"Kein Dokument mit dem angegebenen Fingerprint gefunden."); "Kein Dokument mit dem angegebenen Fingerprint gefunden."));
} else if (lookupResult instanceof PersistenceLookupTechnicalFailure failure) { } else if (lookupResult instanceof PersistenceLookupTechnicalFailure failure) {
logger.warn("Manuelle Dateikopie fehlgeschlagen: Lookup-Fehler. Fingerprint={}, Ursache={}", logger.warn("Manuelle Dateikopie fehlgeschlagen: Lookup-Fehler. Fingerprint={}, Ursache={}",
fingerprint.sha256Hex(), failure.errorMessage()); fingerprint.sha256Hex(), failure.errorMessage());
return new ManualFileCopyPersistenceFailure( return new RecordLookupOutcome(null, new ManualFileCopyPersistenceFailure(
"Persistenzfehler beim Laden des Dokumentstammsatzes: " + failure.errorMessage()); "Persistenzfehler beim Laden des Dokumentstammsatzes: " + failure.errorMessage()));
} else {
// Defensiv: nicht erreichbar dank sealed type, aber erforderlich für die Compiler-
// Vollständigkeitsprüfung in älteren Werkzeugen.
return new ManualFileCopyDocumentNotFound(
"Unbekanntes Lookup-Ergebnis: " + lookupResult.getClass().getSimpleName());
} }
// Defensiv: nicht erreichbar dank sealed type, aber erforderlich für die Compiler-
// Vollständigkeitsprüfung in älteren Werkzeugen.
return new RecordLookupOutcome(null, new ManualFileCopyDocumentNotFound(
"Unbekanntes Lookup-Ergebnis: " + lookupResult.getClass().getSimpleName()));
}
// Schritt 2: Eindeutigen Zieldateinamen über TargetFolderPort auflösen /**
* Löst über {@link TargetFolderPort} einen eindeutigen Zieldateinamen auf.
*
* @param fingerprint der Fingerprint des Dokuments
* @param desiredFullName der gewünschte vollständige Dateiname
* @return Outcome mit aufgelöstem Dateinamen und No-Op-Flag oder mit einem Fehler-Ergebnis
*/
private FilenameLookupOutcome resolveTargetFilename(DocumentFingerprint fingerprint,
String desiredFullName) {
TargetFilenameResolutionResult resolutionResult = TargetFilenameResolutionResult resolutionResult =
targetFolderPort.resolveUniqueFilename(desiredFullName, fingerprint); targetFolderPort.resolveUniqueFilename(desiredFullName, fingerprint);
boolean noOpIdentical = false;
String appliedFileName;
if (resolutionResult instanceof ExistingIdenticalTargetFile identical) { if (resolutionResult instanceof ExistingIdenticalTargetFile identical) {
noOpIdentical = true;
appliedFileName = identical.existingFilename();
logger.info("Manuelle Dateikopie: Identische Datei bereits im Zielordner vorhanden. Fingerprint={}", logger.info("Manuelle Dateikopie: Identische Datei bereits im Zielordner vorhanden. Fingerprint={}",
fingerprint.sha256Hex()); fingerprint.sha256Hex());
return new FilenameLookupOutcome(identical.existingFilename(), true, null);
} else if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) { } else if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
logger.warn("Manuelle Dateikopie fehlgeschlagen: Zielordnerzugriff. Fingerprint={}, Ursache={}", logger.warn("Manuelle Dateikopie fehlgeschlagen: Zielordnerzugriff. Fingerprint={}, Ursache={}",
fingerprint.sha256Hex(), folderFailure.errorMessage()); fingerprint.sha256Hex(), folderFailure.errorMessage());
return new ManualFileCopyFileSystemFailure( return new FilenameLookupOutcome(null, false, new ManualFileCopyFileSystemFailure(
"Zielordner nicht zugänglich: " + folderFailure.errorMessage()); "Zielordner nicht zugänglich: " + folderFailure.errorMessage()));
} else if (resolutionResult instanceof ResolvedTargetFilename resolved) { } else if (resolutionResult instanceof ResolvedTargetFilename resolved) {
appliedFileName = resolved.resolvedFilename(); return new FilenameLookupOutcome(resolved.resolvedFilename(), false, null);
} else { }
return new FilenameLookupOutcome(null, false, new ManualFileCopyFileSystemFailure(
"Unbekanntes Auflösungsergebnis: " + resolutionResult.getClass().getSimpleName()));
}
/**
* Kopiert die Quelldatei physisch in den Zielordner.
*
* @param fingerprint der Fingerprint des Dokuments (für Logging)
* @param record der aktuelle Dokument-Stammsatz
* @param appliedFileName der aufgelöste Zieldateiname
* @return ein Fehler-Ergebnis bei Misserfolg, {@code null} bei Erfolg
*/
private ManualFileCopyResult performFileCopy(DocumentFingerprint fingerprint,
DocumentRecord record,
String appliedFileName) {
var copyResult = targetFileCopyPort.copyToTarget(
record.lastKnownSourceLocator(), appliedFileName);
if (copyResult instanceof TargetFileCopyTechnicalFailure technicalFailure) {
logger.warn("Manuelle Dateikopie fehlgeschlagen: Dateisystemfehler. Fingerprint={}, Ursache={}",
fingerprint.sha256Hex(), technicalFailure.errorMessage());
return new ManualFileCopyFileSystemFailure(technicalFailure.errorMessage());
}
if (!(copyResult instanceof TargetFileCopySuccess)) {
return new ManualFileCopyFileSystemFailure( return new ManualFileCopyFileSystemFailure(
"Unbekanntes Auflösungsergebnis: " + resolutionResult.getClass().getSimpleName()); "Unbekanntes Kopier-Ergebnis: " + copyResult.getClass().getSimpleName());
} }
return null;
}
// Schritt 3: Quelldatei kopieren nur wenn keine identische Zieldatei existiert /**
if (!noOpIdentical) { * Aktualisiert den Dokument-Stammsatz in der Persistenz und gibt das finale
var copyResult = targetFileCopyPort.copyToTarget( * Operationsergebnis zurück. Bei einem Persistenzfehler nach erfolgter Zielkopie
record.lastKnownSourceLocator(), appliedFileName); * wird ein Best-Effort-Rollback der neu geschriebenen Datei durchgeführt.
if (copyResult instanceof TargetFileCopyTechnicalFailure technicalFailure) { *
logger.warn("Manuelle Dateikopie fehlgeschlagen: Dateisystemfehler. Fingerprint={}, Ursache={}", * @param fingerprint der Fingerprint des Dokuments
fingerprint.sha256Hex(), technicalFailure.errorMessage()); * @param record der bisher gültige Dokument-Stammsatz
return new ManualFileCopyFileSystemFailure(technicalFailure.errorMessage()); * @param appliedFileName der tatsächlich verwendete Zieldateiname
} * @param noOpIdentical true, wenn keine neue Kopie geschrieben wurde
if (!(copyResult instanceof TargetFileCopySuccess)) { * @param desiredFullName der ursprünglich gewünschte Zieldateiname
return new ManualFileCopyFileSystemFailure( * @return das finale Operationsergebnis
"Unbekanntes Kopier-Ergebnis: " + copyResult.getClass().getSimpleName()); */
} private ManualFileCopyResult persistAndBuildResult(DocumentFingerprint fingerprint,
} DocumentRecord record,
String appliedFileName,
// Schritt 4: Dokument-Stammsatz aktualisieren boolean noOpIdentical,
String desiredFullName) {
var now = clock.now(); var now = clock.now();
DocumentRecord updatedRecord = new DocumentRecord( DocumentRecord updatedRecord = new DocumentRecord(
record.fingerprint(), record.fingerprint(),
@@ -248,17 +317,15 @@ public class DefaultManualFileCopyUseCase implements ManualFileCopyUseCase {
"Persistenzfehler nach Kopie: " + errorMessage); "Persistenzfehler nach Kopie: " + errorMessage);
} }
boolean conflictSuffixApplied = !noOpIdentical && !appliedFileName.equals(desiredFullName);
if (noOpIdentical) { if (noOpIdentical) {
logger.info("Manuelle Dateikopie abgeschlossen ohne Schreibvorgang: identische Zieldatei {}.", logger.info("Manuelle Dateikopie abgeschlossen ohne Schreibvorgang: identische Zieldatei {}.",
appliedFileName); appliedFileName);
return new ManualFileCopyNoOpIdenticalTarget(appliedFileName); return new ManualFileCopyNoOpIdenticalTarget(appliedFileName);
} }
boolean conflictSuffixApplied = !appliedFileName.equals(desiredFullName);
logger.info("Manuelle Dateikopie erfolgreich: {} (Suffix angewendet: {})", logger.info("Manuelle Dateikopie erfolgreich: {} (Suffix angewendet: {})",
appliedFileName, conflictSuffixApplied); appliedFileName, conflictSuffixApplied);
return new ManualFileCopySuccess(appliedFileName, conflictSuffixApplied); return new ManualFileCopySuccess(appliedFileName, conflictSuffixApplied);
} }
} }
@@ -0,0 +1,311 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
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.BatchRunTrigger;
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerConfig;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerPort;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsPort;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
/**
* Implementiert {@link SchedulerControlUseCase} als zentraler Orchestrator
* des automatischen Schedulers.
* <p>
* Dieser Use Case:
* <ul>
* <li>Verwaltet den Scheduler-Lebenszyklus (Start, Stop) über einen
* {@link SchedulerPort}.</li>
* <li>Hält den exklusiven OS-Lock auf die Konfigurationsdatei über
* {@link ConfigurationFileLockPort}, solange der Scheduler aktiv ist.</li>
* <li>Liest beim Erstellen das konfigurierte Intervall via
* {@link SchedulerSettingsPort}.</li>
* <li>Publiziert unveränderliche Zustandssnapshots via {@link #getStatus()},
* die threadsicher über eine {@link AtomicReference} verwaltet werden.</li>
* </ul>
* <p>
* Alle Zustandsübergänge erfolgen atomar. Der {@link BatchRunTrigger} wird
* pro Tick in einem internen Wrapper ausgeführt, der vor dem Aufruf den
* Zustand auf {@link SchedulerState#RUNNING_BATCH_ACTIVE} setzt und nach
* Abschluss den Folgezustand ableitet.
* <p>
* {@link #start()} und {@link #stop()} sind idempotent und dürfen serialisiert
* vom GUI-Worker-Thread aufgerufen werden. {@link #getStatus()} ist jederzeit
* von beliebigen Threads lesbar.
*/
public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase {
private static final Logger logger =
LogManager.getLogger(DefaultSchedulerControlUseCase.class);
private static final int MINIMUM_INTERVAL_SECONDS = 30;
private final SchedulerPort schedulerPort;
private final ConfigurationFileLockPort lockPort;
private final SchedulerSettingsPort settingsPort;
private final BatchRunTrigger batchRunTrigger;
private final AtomicReference<SchedulerStatus> statusRef;
private final int intervalSeconds;
/**
* Erstellt einen neuen Use Case.
* <p>
* Das Scheduler-Intervall wird sofort aus {@code settingsPort} gelesen;
* Werte unter {@value MINIMUM_INTERVAL_SECONDS} Sekunden werden auf diesen
* Mindestwert angehoben.
*
* @param schedulerPort technischer Scheduler-Mechanismus
* @param lockPort OS-Lock auf die Konfigurationsdatei
* @param settingsPort Lese-/Schreibzugriff auf Scheduler-Einstellungen
* @param batchRunTrigger Auslöser für den eigentlichen Verarbeitungslauf
*/
public DefaultSchedulerControlUseCase(
SchedulerPort schedulerPort,
ConfigurationFileLockPort lockPort,
SchedulerSettingsPort settingsPort,
BatchRunTrigger batchRunTrigger) {
this.schedulerPort = Objects.requireNonNull(schedulerPort, "schedulerPort darf nicht null sein");
this.lockPort = Objects.requireNonNull(lockPort, "lockPort darf nicht null sein");
this.settingsPort = Objects.requireNonNull(settingsPort, "settingsPort darf nicht null sein");
this.batchRunTrigger = Objects.requireNonNull(batchRunTrigger, "batchRunTrigger darf nicht null sein");
SchedulerSettings settings = settingsPort.loadSettings();
this.intervalSeconds = Math.max(settings.intervalSeconds(), MINIMUM_INTERVAL_SECONDS);
this.statusRef = new AtomicReference<>(SchedulerStatus.initial());
}
// -------------------------------------------------------------------------
// SchedulerControlUseCase
// -------------------------------------------------------------------------
/**
* Startet den automatischen Scheduler.
* <p>
* Ist der Scheduler bereits aktiv, hat dieser Aufruf keine Wirkung (idempotent).
* Schlägt ein Startschritt fehl, wird ein vollständiger Rollback durchgeführt
* und der Zustand auf {@link SchedulerState#STOPPED} zurückgesetzt.
*
* @throws SchedulerStartException wenn der Start fehlschlägt
*/
@Override
public void start() throws SchedulerStartException {
SchedulerStatus current = statusRef.get();
if (current.state().isActive()) {
logger.debug("Scheduler ist bereits aktiv Start-Aufruf wird ignoriert.");
return;
}
// Schritt 1: Zustand auf STARTING setzen
statusRef.set(withState(current, SchedulerState.STARTING, Optional.empty()));
// Schritt 2: OS-Lock erwerben
try {
lockPort.acquireLock();
} catch (Exception e) {
logger.error("Scheduler konnte nicht gestartet werden: Lock nicht erwerbbar.", e);
statusRef.set(SchedulerStatus.initial());
throw new SchedulerStartException(
"Konfigurationsdatei konnte nicht gesperrt werden.", e);
}
// Schritt 3: Scheduler-Adapter starten
try {
schedulerPort.startScheduler(new SchedulerConfig(intervalSeconds), this::executeWrappedTick);
} catch (Exception e) {
logger.error("Scheduler konnte nicht gestartet werden: Adapter-Start fehlgeschlagen.", e);
lockPort.releaseLock();
statusRef.set(SchedulerStatus.initial());
throw new SchedulerStartException(
"Scheduler-Adapter konnte nicht gestartet werden.", e);
}
// Schritt 4: Zustand auf RUNNING_IDLE setzen, Sitzungstotal auf null zurücksetzen
Instant nextTick = Instant.now().plusSeconds(intervalSeconds);
statusRef.updateAndGet(s -> new SchedulerStatus(
SchedulerState.RUNNING_IDLE,
s.lastRunEndedAt(),
s.lastRunSummary(),
Optional.of(nextTick),
s.lastError(),
Optional.of(SchedulerSessionTotals.zero())));
logger.info("Scheduler gestartet. Intervall: {} Sekunden.", intervalSeconds);
}
/**
* Stoppt den automatischen Scheduler.
* <p>
* Ist der Scheduler bereits gestoppt, hat dieser Aufruf keine Wirkung
* (idempotent). Läuft gerade ein Batch-Tick, wechselt der Zustand zu
* {@link SchedulerState#STOPPING_BATCH_ACTIVE}; der laufende Batch wird
* regulär zu Ende geführt, danach wird der Zustand auf
* {@link SchedulerState#STOPPED} gesetzt.
*/
@Override
public void stop() {
SchedulerStatus previous;
SchedulerStatus updated;
do {
previous = statusRef.get();
if (!previous.state().isActive()) {
logger.debug("Scheduler ist bereits gestoppt Stop-Aufruf wird ignoriert.");
return;
}
SchedulerState nextState = previous.state().isBatchRunning()
? SchedulerState.STOPPING_BATCH_ACTIVE
: SchedulerState.STOPPED;
updated = withState(previous, nextState, Optional.empty());
} while (!statusRef.compareAndSet(previous, updated));
boolean batchWasRunning = previous.state().isBatchRunning();
schedulerPort.stopScheduler();
if (!batchWasRunning) {
lockPort.releaseLock();
logger.info("Scheduler gestoppt.");
} else {
logger.info("Stop angefordert laufender Batch wird abgewartet.");
}
}
/**
* Gibt den aktuellen Scheduler-Zustand als unveränderlichen Snapshot zurück.
*
* @return aktueller Scheduler-Status; nie {@code null}
*/
@Override
public SchedulerStatus getStatus() {
return statusRef.get();
}
/**
* Gibt das beim Start der Anwendung geladene Ausführungsintervall in Sekunden zurück.
*
* @return Intervall in Sekunden; immer &ge; 30
*/
@Override
public int getIntervalSeconds() {
return intervalSeconds;
}
/**
* Persistiert das Ausführungsintervall in die Konfigurationsdatei.
* <p>
* Der in-Memory-Wert wird nicht aktualisiert; der neue Wert wird beim
* nächsten Anwendungsstart gelesen.
*
* @param seconds Intervall in Sekunden
*/
@Override
public void saveIntervalSeconds(int seconds) {
settingsPort.saveIntervalSeconds(seconds);
}
// -------------------------------------------------------------------------
// Tick-Wrapper (package-private für Testbarkeit)
// -------------------------------------------------------------------------
/**
* Wrapping-Implementierung des {@link BatchRunTrigger}, die der
* {@link SchedulerPort} bei jedem Tick synchron aufruft.
* <p>
* Setzt vor dem Aufruf den Zustand auf
* {@link SchedulerState#RUNNING_BATCH_ACTIVE} und leitet nach dem
* Abschluss den Folgezustand ab:
* <ul>
* <li>{@link SchedulerState#RUNNING_IDLE} bei normalem Weiterlauf</li>
* <li>{@link SchedulerState#STOPPED} wenn ein Stopp-Befehl empfangen wurde</li>
* </ul>
* Im Fall {@link SchedulerState#STOPPED} wird außerdem der OS-Lock freigegeben.
* <p>
* Package-private, damit Unit-Tests den Tick-Wrapper direkt aufrufen können
* ohne den Scheduler-Executor zu starten.
*
* @return das Ergebnis des delegierten {@link BatchRunTrigger#triggerRun()}
*/
BatchRunTriggerResult executeWrappedTick() {
// Zustand auf RUNNING_BATCH_ACTIVE setzen
statusRef.updateAndGet(s -> withState(s, SchedulerState.RUNNING_BATCH_ACTIVE, Optional.empty()));
// Eigentlichen Batch ausführen
BatchRunTriggerResult result = batchRunTrigger.triggerRun();
// Folgezustand aus Ergebnis und aktuellem Zustand ableiten
SchedulerStatus afterBatch = statusRef.get();
boolean stopping = afterBatch.state() == SchedulerState.STOPPING_BATCH_ACTIVE;
SchedulerState nextState = stopping ? SchedulerState.STOPPED : SchedulerState.RUNNING_IDLE;
Optional<Instant> nextTickAt = stopping
? Optional.empty()
: Optional.of(Instant.now().plusSeconds(intervalSeconds));
Optional<Instant> lastRunEndedAt = afterBatch.lastRunEndedAt();
Optional<RunSummary> lastRunSummary = afterBatch.lastRunSummary();
Optional<String> lastError = afterBatch.lastError();
Optional<SchedulerSessionTotals> sessionTotals = afterBatch.sessionTotals();
switch (result) {
case BatchRunTriggerResult.Started started -> {
lastRunEndedAt = Optional.of(started.endedAt());
lastRunSummary = Optional.of(started.summary());
lastError = Optional.empty();
sessionTotals = Optional.of(sessionTotals
.orElse(SchedulerSessionTotals.zero())
.plus(started.summary().successCount(),
started.summary().failedCount()));
logger.info("Scheduler-Tick abgeschlossen: {} erfolgreich, {} fehlgeschlagen, {} übersprungen.",
started.summary().successCount(),
started.summary().failedCount(),
started.summary().skippedCount());
}
case BatchRunTriggerResult.SkippedBusy ignored ->
logger.debug("Scheduler-Tick übersprungen: anderer Lauf aktiv.");
case BatchRunTriggerResult.Failed failed -> {
lastError = Optional.of(failed.userMessage());
logger.warn("Scheduler-Tick fehlgeschlagen: {}", failed.technicalMessage());
}
}
statusRef.set(new SchedulerStatus(
nextState,
lastRunEndedAt,
lastRunSummary,
nextTickAt,
lastError,
sessionTotals));
if (stopping) {
lockPort.releaseLock();
logger.info("Scheduler gestoppt nach Abschluss des laufenden Batches.");
}
return result;
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static SchedulerStatus withState(
SchedulerStatus base, SchedulerState state, Optional<Instant> nextTickAt) {
return new SchedulerStatus(
state,
base.lastRunEndedAt(),
base.lastRunSummary(),
nextTickAt,
base.lastError(),
base.sessionTotals());
}
}
@@ -24,6 +24,8 @@ public record EditorValidationFinding(
Optional<String> fieldKey, Optional<String> fieldKey,
EditorValidationSeverity severity, EditorValidationSeverity severity,
String message) { String message) {
private static final String FIELD_KEY_NOT_NULL = "fieldKey must not be null";
/** /**
* Erstellt einen neuen Validierungsbefund. * Erstellt einen neuen Validierungsbefund.
@@ -36,7 +38,7 @@ public record EditorValidationFinding(
public EditorValidationFinding { public EditorValidationFinding {
Objects.requireNonNull(severity, "severity must not be null"); Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(message, "message must not be null"); Objects.requireNonNull(message, "message must not be null");
fieldKey = fieldKey == null ? Optional.empty() : fieldKey; fieldKey = Objects.requireNonNullElse(fieldKey, Optional.empty());
} }
/** /**
@@ -47,7 +49,7 @@ public record EditorValidationFinding(
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#ERROR} * @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#ERROR}
*/ */
public static EditorValidationFinding error(String fieldKey, String message) { public static EditorValidationFinding error(String fieldKey, String message) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null"); Objects.requireNonNull(fieldKey, FIELD_KEY_NOT_NULL);
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.ERROR, message); return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.ERROR, message);
} }
@@ -59,7 +61,7 @@ public record EditorValidationFinding(
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#WARNING} * @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#WARNING}
*/ */
public static EditorValidationFinding warning(String fieldKey, String message) { public static EditorValidationFinding warning(String fieldKey, String message) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null"); Objects.requireNonNull(fieldKey, FIELD_KEY_NOT_NULL);
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.WARNING, message); return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.WARNING, message);
} }
@@ -71,7 +73,7 @@ public record EditorValidationFinding(
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#HINT} * @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#HINT}
*/ */
public static EditorValidationFinding hint(String fieldKey, String message) { public static EditorValidationFinding hint(String fieldKey, String message) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null"); Objects.requireNonNull(fieldKey, FIELD_KEY_NOT_NULL);
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.HINT, message); return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.HINT, message);
} }
@@ -93,7 +95,7 @@ public record EditorValidationFinding(
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO} * @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO}
*/ */
public static EditorValidationFinding info(String fieldKey, String message) { public static EditorValidationFinding info(String fieldKey, String message) {
Objects.requireNonNull(fieldKey, "fieldKey must not be null"); Objects.requireNonNull(fieldKey, FIELD_KEY_NOT_NULL);
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.INFO, message); return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.INFO, message);
} }
@@ -89,9 +89,7 @@ public sealed interface CheckpointResult
Objects.requireNonNull(checkpointId, "checkpointId must not be null"); Objects.requireNonNull(checkpointId, "checkpointId must not be null");
Objects.requireNonNull(severity, "severity must not be null"); Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(message, "message must not be null"); Objects.requireNonNull(message, "message must not be null");
correctionSuggestion = correctionSuggestion == null correctionSuggestion = Objects.requireNonNullElse(correctionSuggestion, Optional.empty());
? Optional.empty()
: correctionSuggestion;
} }
/** /**
@@ -21,6 +21,8 @@ public sealed interface CorrectionOutcome
permits CorrectionOutcome.Applied, permits CorrectionOutcome.Applied,
CorrectionOutcome.Failed, CorrectionOutcome.Failed,
CorrectionOutcome.NotAttempted { CorrectionOutcome.NotAttempted {
String SUGGESTION_NOT_NULL = "suggestion must not be null";
/** /**
* Gibt den Korrekturvorschlag zurück, auf den sich dieses Ergebnis bezieht. * Gibt den Korrekturvorschlag zurück, auf den sich dieses Ergebnis bezieht.
@@ -47,7 +49,7 @@ public sealed interface CorrectionOutcome
* @throws NullPointerException wenn ein Parameter {@code null} ist * @throws NullPointerException wenn ein Parameter {@code null} ist
*/ */
public Applied { public Applied {
Objects.requireNonNull(suggestion, "suggestion must not be null"); Objects.requireNonNull(suggestion, SUGGESTION_NOT_NULL);
Objects.requireNonNull(message, "message must not be null"); Objects.requireNonNull(message, "message must not be null");
} }
} }
@@ -70,7 +72,7 @@ public sealed interface CorrectionOutcome
* @throws NullPointerException wenn ein Parameter {@code null} ist * @throws NullPointerException wenn ein Parameter {@code null} ist
*/ */
public Failed { public Failed {
Objects.requireNonNull(suggestion, "suggestion must not be null"); Objects.requireNonNull(suggestion, SUGGESTION_NOT_NULL);
Objects.requireNonNull(errorMessage, "errorMessage must not be null"); Objects.requireNonNull(errorMessage, "errorMessage must not be null");
} }
} }
@@ -97,7 +99,7 @@ public sealed interface CorrectionOutcome
* @throws NullPointerException wenn ein Parameter {@code null} ist * @throws NullPointerException wenn ein Parameter {@code null} ist
*/ */
public NotAttempted { public NotAttempted {
Objects.requireNonNull(suggestion, "suggestion must not be null"); Objects.requireNonNull(suggestion, SUGGESTION_NOT_NULL);
Objects.requireNonNull(reason, "reason must not be null"); Objects.requireNonNull(reason, "reason must not be null");
} }
} }
@@ -21,6 +21,9 @@ public sealed interface CorrectionSuggestion
permits CorrectionSuggestion.CreateDirectory, permits CorrectionSuggestion.CreateDirectory,
CorrectionSuggestion.CreatePromptFile, CorrectionSuggestion.CreatePromptFile,
CorrectionSuggestion.PrepareSqlitePath { CorrectionSuggestion.PrepareSqlitePath {
String PATH_NOT_NULL = "path must not be null";
String DESCRIPTION_NOT_NULL = "descriptionForUser must not be null";
/** /**
* Gibt eine kurze deutsche Beschreibung der vorgeschlagenen Korrektur zurück, * Gibt eine kurze deutsche Beschreibung der vorgeschlagenen Korrektur zurück,
@@ -53,8 +56,8 @@ public sealed interface CorrectionSuggestion
* @throws IllegalArgumentException wenn {@code path} leer ist * @throws IllegalArgumentException wenn {@code path} leer ist
*/ */
public CreateDirectory { public CreateDirectory {
Objects.requireNonNull(path, "path must not be null"); Objects.requireNonNull(path, PATH_NOT_NULL);
Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null"); Objects.requireNonNull(descriptionForUser, DESCRIPTION_NOT_NULL);
if (path.isBlank()) { if (path.isBlank()) {
throw new IllegalArgumentException("path must not be blank"); throw new IllegalArgumentException("path must not be blank");
} }
@@ -93,8 +96,8 @@ public sealed interface CorrectionSuggestion
* {@code maxTitleLength < 1} * {@code maxTitleLength < 1}
*/ */
public CreatePromptFile { public CreatePromptFile {
Objects.requireNonNull(path, "path must not be null"); Objects.requireNonNull(path, PATH_NOT_NULL);
Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null"); Objects.requireNonNull(descriptionForUser, DESCRIPTION_NOT_NULL);
if (path.isBlank()) { if (path.isBlank()) {
throw new IllegalArgumentException("path must not be blank"); throw new IllegalArgumentException("path must not be blank");
} }
@@ -129,8 +132,8 @@ public sealed interface CorrectionSuggestion
* @throws IllegalArgumentException wenn {@code path} leer ist * @throws IllegalArgumentException wenn {@code path} leer ist
*/ */
public PrepareSqlitePath { public PrepareSqlitePath {
Objects.requireNonNull(path, "path must not be null"); Objects.requireNonNull(path, PATH_NOT_NULL);
Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null"); Objects.requireNonNull(descriptionForUser, DESCRIPTION_NOT_NULL);
if (path.isBlank()) { if (path.isBlank()) {
throw new IllegalArgumentException("path must not be blank"); throw new IllegalArgumentException("path must not be blank");
} }
@@ -1121,6 +1121,11 @@ class BatchRunProcessingUseCaseTest {
@Override @Override
public void release() { releaseCalled = true; } public void release() { releaseCalled = true; }
@Override
public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
return java.util.Optional.empty();
}
boolean wasAcquireCalled() { return acquireCalled; } boolean wasAcquireCalled() { return acquireCalled; }
boolean wasReleaseCalled() { return releaseCalled; } boolean wasReleaseCalled() { return releaseCalled; }
} }
@@ -1142,6 +1147,11 @@ class BatchRunProcessingUseCaseTest {
@Override @Override
public void release() { releaseCount++; } public void release() { releaseCount++; }
@Override
public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
return java.util.Optional.empty();
}
int acquireCallCount() { return acquireCount; } int acquireCallCount() { return acquireCount; }
int releaseCallCount() { return releaseCount; } int releaseCallCount() { return releaseCount; }
} }
@@ -1157,6 +1167,11 @@ class BatchRunProcessingUseCaseTest {
@Override @Override
public void release() { releaseCalled = true; } public void release() { releaseCalled = true; }
@Override
public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
return java.util.Optional.empty();
}
boolean wasAcquireCalled() { return acquireCalled; } boolean wasAcquireCalled() { return acquireCalled; }
boolean wasReleaseCalled() { return releaseCalled; } boolean wasReleaseCalled() { return releaseCalled; }
} }
@@ -1637,6 +1652,7 @@ class BatchRunProcessingUseCaseTest {
@Override @Override
public void debugSensitiveAiContent(String message, Object... args) { public void debugSensitiveAiContent(String message, Object... args) {
// intentionally empty
} }
@Override @Override
@@ -300,8 +300,16 @@ class BatchRunProgressObservationTest {
} }
private static final class NoOpLock implements RunLockPort { private static final class NoOpLock implements RunLockPort {
@Override public void acquire() { } @Override public void acquire() {
@Override public void release() { } // intentionally empty
}
@Override public void release() {
// intentionally empty
}
@Override
public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
return java.util.Optional.empty();
}
} }
private static final class EmptyCandidatesPort implements SourceDocumentCandidatesPort { private static final class EmptyCandidatesPort implements SourceDocumentCandidatesPort {
@@ -331,11 +339,21 @@ class BatchRunProgressObservationTest {
} }
private static final class SilentLogger implements ProcessingLogger { private static final class SilentLogger implements ProcessingLogger {
@Override public void info(String message, Object... args) { } @Override public void info(String message, Object... args) {
@Override public void warn(String message, Object... args) { } // intentionally empty
@Override public void error(String message, Object... args) { } }
@Override public void debug(String message, Object... args) { } @Override public void warn(String message, Object... args) {
@Override public void debugSensitiveAiContent(String message, Object... args) { } // intentionally empty
}
@Override public void error(String message, Object... args) {
// intentionally empty
}
@Override public void debug(String message, Object... args) {
// intentionally empty
}
@Override public void debugSensitiveAiContent(String message, Object... args) {
// intentionally empty
}
} }
private static final class RecordingObserver implements BatchRunProgressObserver { private static final class RecordingObserver implements BatchRunProgressObserver {
@@ -461,21 +479,31 @@ class BatchRunProgressObservationTest {
@Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint f) { @Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint f) {
return new DocumentUnknown(); return new DocumentUnknown();
} }
@Override public void create(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { } @Override public void create(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) {
@Override public void update(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { } // intentionally empty
@Override public void deleteByFingerprint(DocumentFingerprint fingerprint) { } }
@Override public void update(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) {
// intentionally empty
}
@Override public void deleteByFingerprint(DocumentFingerprint fingerprint) {
// intentionally empty
}
} }
private static final class NoAttempts implements ProcessingAttemptRepository { private static final class NoAttempts implements ProcessingAttemptRepository {
static final NoAttempts INSTANCE = new NoAttempts(); static final NoAttempts INSTANCE = new NoAttempts();
@Override public int loadNextAttemptNumber(DocumentFingerprint fingerprint) { return 1; } @Override public int loadNextAttemptNumber(DocumentFingerprint fingerprint) { return 1; }
@Override public void save(ProcessingAttempt attempt) { } @Override public void save(ProcessingAttempt attempt) {
// intentionally empty
}
@Override public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) { @Override public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
return List.of(); return List.of();
} }
@Override public ProcessingAttempt findLatestProposalReadyAttempt( @Override public ProcessingAttempt findLatestProposalReadyAttempt(
DocumentFingerprint fingerprint) { return null; } DocumentFingerprint fingerprint) { return null; }
@Override public void deleteAllByFingerprint(DocumentFingerprint fingerprint) { } @Override public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
// intentionally empty
}
} }
private static final class NoUow implements UnitOfWorkPort { private static final class NoUow implements UnitOfWorkPort {
@@ -495,7 +523,9 @@ class BatchRunProgressObservationTest {
return new ResolvedTargetFilename(baseFilename); return new ResolvedTargetFilename(baseFilename);
} }
@Override public String getTargetFolderLocator() { return "/tmp/target"; } @Override public String getTargetFolderLocator() { return "/tmp/target"; }
@Override public void tryDeleteTargetFile(String filename) { } @Override public void tryDeleteTargetFile(String filename) {
// intentionally empty
}
} }
private static final class NoTargetCopy implements TargetFileCopyPort { private static final class NoTargetCopy implements TargetFileCopyPort {
@@ -0,0 +1,210 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase.CreateNewDatabaseResult;
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
/**
* Unit-Tests für {@link DefaultCreateNewDatabaseUseCase}.
* <p>
* Prüft den Orchestrierungsablauf, die Pfad-Sicherheitsprüfung, das Aufräumen der
* temporären Datei im Fehlerfall und das Umstellen der aktiven DB-Referenz im
* Erfolgsfall. Die DB-Adapter werden über Stubs ersetzt, damit die Tests ohne
* SQLite-/Flyway-Infrastruktur laufen.
*/
class DefaultCreateNewDatabaseUseCaseTest {
@Test
void constructor_shouldThrowNullPointerException_whenAnyPortIsNull() {
DatabaseCreationPort creation = tempFile -> new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
ActiveDatabaseContextPort context = stubActiveContext();
DefaultCreateNewDatabaseUseCase.ActiveDatabasePathSupplier supplier = () -> Path.of(".");
assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(null, context, supplier))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("databaseCreationPort");
assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(creation, null, supplier))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("activeDatabaseContextPort");
assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(creation, context, null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("activeDatabasePathSupplier");
}
@Test
void createNewDatabase_shouldThrowNullPointerException_whenTargetIsNull() {
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
tempFile -> new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile),
stubActiveContext(),
() -> Path.of("active.sqlite"));
assertThatThrownBy(() -> useCase.createNewDatabase(null))
.isInstanceOf(NullPointerException.class);
}
@Test
void createNewDatabase_shouldRejectSameAsActiveDatabase(@TempDir Path tempDir) throws IOException {
Path active = tempDir.resolve("active.sqlite");
Files.writeString(active, "stub");
AtomicReference<Path> contextOverride = new AtomicReference<>();
ActiveDatabaseContextPort context = trackingActiveContext(contextOverride);
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
tempFile -> {
throw new AssertionError("DatabaseCreationPort darf bei selber Datei nicht aufgerufen werden");
},
context,
active::toAbsolutePath);
CreateNewDatabaseResult result = useCase.createNewDatabase(active);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.SameAsActiveDatabase.class);
assertThat(contextOverride.get()).isNull();
}
@Test
void createNewDatabase_shouldDeleteTempFileAndKeepActiveContext_whenAdapterReportsFailure(
@TempDir Path tempDir) throws IOException {
Path active = tempDir.resolve("active.sqlite");
Files.writeString(active, "stub");
Path target = tempDir.resolve("new.sqlite");
AtomicReference<Path> capturedTemp = new AtomicReference<>();
DatabaseCreationPort creation = tempFile -> {
// Simuliere, dass der Adapter die Temp-Datei zwar anlegt, aber wegen Migration scheitert
try {
Files.writeString(tempFile, "x");
} catch (IOException e) {
throw new IllegalStateException(e);
}
capturedTemp.set(tempFile);
return new DatabaseCreationPort.DatabaseCreationResult.Failure(
DatabaseCreationPort.DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
"Migration fehlgeschlagen", null);
};
AtomicReference<Path> contextOverride = new AtomicReference<>();
ActiveDatabaseContextPort context = trackingActiveContext(contextOverride);
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
creation, context, active::toAbsolutePath);
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.CreationFailed.class);
CreateNewDatabaseResult.CreationFailed failed = (CreateNewDatabaseResult.CreationFailed) result;
assertThat(failed.phase()).isEqualTo(CreateNewDatabaseResult.Phase.SCHEMA_MIGRATION);
assertThat(capturedTemp.get()).isNotNull();
assertThat(Files.exists(capturedTemp.get())).isFalse();
assertThat(Files.exists(target)).isFalse();
assertThat(contextOverride.get()).isNull();
}
@Test
void createNewDatabase_shouldMoveTempToTargetAndSwitchContext_onSuccess(@TempDir Path tempDir)
throws IOException {
Path active = tempDir.resolve("active.sqlite");
Files.writeString(active, "stub");
Path target = tempDir.resolve("new.sqlite");
AtomicReference<Path> capturedTemp = new AtomicReference<>();
DatabaseCreationPort creation = tempFile -> {
try {
Files.writeString(tempFile, "migrated-content");
} catch (IOException e) {
throw new IllegalStateException(e);
}
capturedTemp.set(tempFile);
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
};
AtomicReference<Path> contextOverride = new AtomicReference<>();
ActiveDatabaseContextPort context = trackingActiveContext(contextOverride);
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
creation, context, active::toAbsolutePath);
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
assertThat(Files.exists(target)).isTrue();
assertThat(Files.readString(target)).isEqualTo("migrated-content");
assertThat(capturedTemp.get()).isNotNull();
assertThat(Files.exists(capturedTemp.get())).isFalse();
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
}
@Test
void createNewDatabase_shouldOverwriteExistingTargetFileAtomically(@TempDir Path tempDir) throws IOException {
Path active = tempDir.resolve("active.sqlite");
Files.writeString(active, "active");
Path target = tempDir.resolve("existing.sqlite");
Files.writeString(target, "alter-inhalt");
DatabaseCreationPort creation = tempFile -> {
try {
Files.writeString(tempFile, "neu-und-leer");
} catch (IOException e) {
throw new IllegalStateException(e);
}
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
};
AtomicReference<Path> contextOverride = new AtomicReference<>();
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
creation, trackingActiveContext(contextOverride), active::toAbsolutePath);
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
assertThat(Files.readString(target)).isEqualTo("neu-und-leer");
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
}
@Test
void isSameFile_shouldReturnTrue_forIdenticalNonExistingFiles(@TempDir Path tempDir) throws IOException {
Path a = tempDir.resolve("data.sqlite");
Path b = tempDir.resolve("data.sqlite");
assertThat(DefaultCreateNewDatabaseUseCase.isSameFile(a, b)).isTrue();
}
@Test
void isSameFile_shouldReturnFalse_forDifferentFilesInSameDirectory(@TempDir Path tempDir) throws IOException {
Path a = tempDir.resolve("a.sqlite");
Path b = tempDir.resolve("b.sqlite");
assertThat(DefaultCreateNewDatabaseUseCase.isSameFile(a, b)).isFalse();
}
private static ActiveDatabaseContextPort stubActiveContext() {
return new ActiveDatabaseContextPort() {
@Override
public void switchActiveDatabase(Path newDbFile) { /* no-op */ }
@Override
public Optional<Path> activeDatabaseOverride() { return Optional.empty(); }
};
}
/**
* Aktive Context-Implementierung, die das übergebene Override in der gegebenen
* {@link AtomicReference} festhält, damit Tests die Aufruf-Sequenz prüfen können.
*/
private static ActiveDatabaseContextPort trackingActiveContext(AtomicReference<Path> sink) {
return new ActiveDatabaseContextPort() {
@Override
public void switchActiveDatabase(Path newDbFile) {
sink.set(newDbFile);
}
@Override
public Optional<Path> activeDatabaseOverride() {
return Optional.ofNullable(sink.get());
}
};
}
}
@@ -83,17 +83,25 @@ class DefaultDeleteDocumentHistoryUseCaseTest {
UnitOfWorkPort failingPort = operations -> UnitOfWorkPort failingPort = operations ->
operations.accept(new UnitOfWorkPort.TransactionOperations() { operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override @Override
public void saveProcessingAttempt(ProcessingAttempt attempt) { } public void saveProcessingAttempt(ProcessingAttempt attempt) {
// intentionally empty
}
@Override @Override
public void createDocumentRecord(DocumentRecord record) { } public void createDocumentRecord(DocumentRecord record) {
// intentionally empty
}
@Override @Override
public void updateDocumentRecord(DocumentRecord record) { } public void updateDocumentRecord(DocumentRecord record) {
// intentionally empty
}
@Override @Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
throw new DocumentPersistenceException("Simulated DB error"); throw new DocumentPersistenceException("Simulated DB error");
} }
@Override @Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { } public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
// intentionally empty
}
}); });
DefaultDeleteDocumentHistoryUseCase useCase = DefaultDeleteDocumentHistoryUseCase useCase =
@@ -110,11 +118,21 @@ class DefaultDeleteDocumentHistoryUseCaseTest {
private static UnitOfWorkPort noOpPort() { private static UnitOfWorkPort noOpPort() {
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() { return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override public void saveProcessingAttempt(ProcessingAttempt a) { } @Override public void saveProcessingAttempt(ProcessingAttempt a) {
@Override public void createDocumentRecord(DocumentRecord r) { } // intentionally empty
@Override public void updateDocumentRecord(DocumentRecord r) { } }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { } @Override public void createDocumentRecord(DocumentRecord r) {
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { } // intentionally empty
}
@Override public void updateDocumentRecord(DocumentRecord r) {
// intentionally empty
}
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) {
// intentionally empty
}
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) {
// intentionally empty
}
}); });
} }
@@ -127,9 +145,15 @@ class DefaultDeleteDocumentHistoryUseCaseTest {
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>(); final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>(); final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
@Override public void saveProcessingAttempt(ProcessingAttempt a) { } @Override public void saveProcessingAttempt(ProcessingAttempt a) {
@Override public void createDocumentRecord(DocumentRecord r) { } // intentionally empty
@Override public void updateDocumentRecord(DocumentRecord r) { } }
@Override public void createDocumentRecord(DocumentRecord r) {
// intentionally empty
}
@Override public void updateDocumentRecord(DocumentRecord r) {
// intentionally empty
}
@Override @Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
@@ -6,8 +6,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
@@ -86,13 +84,21 @@ class DefaultHistoryResetDocumentStatusUseCaseTest {
UnitOfWorkPort failingPort = operations -> UnitOfWorkPort failingPort = operations ->
operations.accept(new UnitOfWorkPort.TransactionOperations() { operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override @Override
public void saveProcessingAttempt(ProcessingAttempt attempt) { } public void saveProcessingAttempt(ProcessingAttempt attempt) {
// intentionally empty
}
@Override @Override
public void createDocumentRecord(DocumentRecord record) { } public void createDocumentRecord(DocumentRecord record) {
// intentionally empty
}
@Override @Override
public void updateDocumentRecord(DocumentRecord record) { } public void updateDocumentRecord(DocumentRecord record) {
// intentionally empty
}
@Override @Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// intentionally empty
}
@Override @Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
throw new DocumentPersistenceException("Simulated DB error"); throw new DocumentPersistenceException("Simulated DB error");
@@ -113,11 +119,21 @@ class DefaultHistoryResetDocumentStatusUseCaseTest {
private static UnitOfWorkPort noOpPort() { private static UnitOfWorkPort noOpPort() {
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() { return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override public void saveProcessingAttempt(ProcessingAttempt a) { } @Override public void saveProcessingAttempt(ProcessingAttempt a) {
@Override public void createDocumentRecord(DocumentRecord r) { } // intentionally empty
@Override public void updateDocumentRecord(DocumentRecord r) { } }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { } @Override public void createDocumentRecord(DocumentRecord r) {
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { } // intentionally empty
}
@Override public void updateDocumentRecord(DocumentRecord r) {
// intentionally empty
}
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) {
// intentionally empty
}
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) {
// intentionally empty
}
}); });
} }
@@ -130,9 +146,15 @@ class DefaultHistoryResetDocumentStatusUseCaseTest {
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>(); final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>(); final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
@Override public void saveProcessingAttempt(ProcessingAttempt a) { } @Override public void saveProcessingAttempt(ProcessingAttempt a) {
@Override public void createDocumentRecord(DocumentRecord r) { } // intentionally empty
@Override public void updateDocumentRecord(DocumentRecord r) { } }
@Override public void createDocumentRecord(DocumentRecord r) {
// intentionally empty
}
@Override public void updateDocumentRecord(DocumentRecord r) {
// intentionally empty
}
@Override @Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
@@ -100,11 +100,21 @@ class DefaultManualFileCopyUseCaseTest {
private static ProcessingLogger noOpLogger() { private static ProcessingLogger noOpLogger() {
return new ProcessingLogger() { return new ProcessingLogger() {
@Override public void info(String msg, Object... args) { } @Override public void info(String msg, Object... args) {
@Override public void debug(String msg, Object... args) { } // intentionally empty
@Override public void debugSensitiveAiContent(String msg, Object... args) { } }
@Override public void warn(String msg, Object... args) { } @Override public void debug(String msg, Object... args) {
@Override public void error(String msg, Object... args) { } // intentionally empty
}
@Override public void debugSensitiveAiContent(String msg, Object... args) {
// intentionally empty
}
@Override public void warn(String msg, Object... args) {
// intentionally empty
}
@Override public void error(String msg, Object... args) {
// intentionally empty
}
}; };
} }
@@ -115,9 +125,15 @@ class DefaultManualFileCopyUseCaseTest {
private static DocumentRecordRepository repositoryReturning(DocumentRecordLookupResult result) { private static DocumentRecordRepository repositoryReturning(DocumentRecordLookupResult result) {
return new DocumentRecordRepository() { return new DocumentRecordRepository() {
@Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fp) { return result; } @Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fp) { return result; }
@Override public void create(DocumentRecord r) { } @Override public void create(DocumentRecord r) {
@Override public void update(DocumentRecord r) { } // intentionally empty
@Override public void deleteByFingerprint(DocumentFingerprint fp) { } }
@Override public void update(DocumentRecord r) {
// intentionally empty
}
@Override public void deleteByFingerprint(DocumentFingerprint fp) {
// intentionally empty
}
}; };
} }
@@ -125,7 +141,9 @@ class DefaultManualFileCopyUseCaseTest {
return new TargetFolderPort() { return new TargetFolderPort() {
@Override public String getTargetFolderLocator() { return "/zielordner"; } @Override public String getTargetFolderLocator() { return "/zielordner"; }
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { return result; } @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { return result; }
@Override public void tryDeleteTargetFile(String name) { } @Override public void tryDeleteTargetFile(String name) {
// intentionally empty
}
}; };
} }
@@ -439,7 +457,9 @@ class DefaultManualFileCopyUseCaseTest {
baseNames.add(baseName); baseNames.add(baseName);
return new ResolvedTargetFilename(baseName); return new ResolvedTargetFilename(baseName);
} }
@Override public void tryDeleteTargetFile(String name) { } @Override public void tryDeleteTargetFile(String name) {
// intentionally empty
}
}; };
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase( DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
@@ -545,11 +565,21 @@ class DefaultManualFileCopyUseCaseTest {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private static class NoOpTransactionOperations implements UnitOfWorkPort.TransactionOperations { private static class NoOpTransactionOperations implements UnitOfWorkPort.TransactionOperations {
@Override public void saveProcessingAttempt(ProcessingAttempt attempt) { } @Override public void saveProcessingAttempt(ProcessingAttempt attempt) {
@Override public void createDocumentRecord(DocumentRecord record) { } // intentionally empty
@Override public void updateDocumentRecord(DocumentRecord record) { } }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } @Override public void createDocumentRecord(DocumentRecord record) {
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { } // intentionally empty
}
@Override public void updateDocumentRecord(DocumentRecord record) {
// intentionally empty
}
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// intentionally empty
}
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
// intentionally empty
}
} }
private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations { private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations {
@@ -559,10 +589,18 @@ class DefaultManualFileCopyUseCaseTest {
this.captured = captured; this.captured = captured;
} }
@Override public void saveProcessingAttempt(ProcessingAttempt attempt) { } @Override public void saveProcessingAttempt(ProcessingAttempt attempt) {
@Override public void createDocumentRecord(DocumentRecord record) { } // intentionally empty
}
@Override public void createDocumentRecord(DocumentRecord record) {
// intentionally empty
}
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); } @Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { } // intentionally empty
}
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
// intentionally empty
}
} }
} }
@@ -118,11 +118,21 @@ class DefaultManualFileRenameUseCaseTest {
private static ProcessingLogger noOpLogger() { private static ProcessingLogger noOpLogger() {
return new ProcessingLogger() { return new ProcessingLogger() {
@Override public void info(String msg, Object... args) { } @Override public void info(String msg, Object... args) {
@Override public void debug(String msg, Object... args) { } // intentionally empty
@Override public void debugSensitiveAiContent(String msg, Object... args) { } }
@Override public void warn(String msg, Object... args) { } @Override public void debug(String msg, Object... args) {
@Override public void error(String msg, Object... args) { } // intentionally empty
}
@Override public void debugSensitiveAiContent(String msg, Object... args) {
// intentionally empty
}
@Override public void warn(String msg, Object... args) {
// intentionally empty
}
@Override public void error(String msg, Object... args) {
// intentionally empty
}
}; };
} }
@@ -133,9 +143,15 @@ class DefaultManualFileRenameUseCaseTest {
private static DocumentRecordRepository repositoryReturning(DocumentRecordLookupResult result) { private static DocumentRecordRepository repositoryReturning(DocumentRecordLookupResult result) {
return new DocumentRecordRepository() { return new DocumentRecordRepository() {
@Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fp) { return result; } @Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fp) { return result; }
@Override public void create(DocumentRecord r) { } @Override public void create(DocumentRecord r) {
@Override public void update(DocumentRecord r) { } // intentionally empty
@Override public void deleteByFingerprint(DocumentFingerprint fp) { } }
@Override public void update(DocumentRecord r) {
// intentionally empty
}
@Override public void deleteByFingerprint(DocumentFingerprint fp) {
// intentionally empty
}
}; };
} }
@@ -143,7 +159,9 @@ class DefaultManualFileRenameUseCaseTest {
return new TargetFolderPort() { return new TargetFolderPort() {
@Override public String getTargetFolderLocator() { return "/zielordner"; } @Override public String getTargetFolderLocator() { return "/zielordner"; }
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { return result; } @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { return result; }
@Override public void tryDeleteTargetFile(String name) { } @Override public void tryDeleteTargetFile(String name) {
// intentionally empty
}
}; };
} }
@@ -475,7 +493,9 @@ class DefaultManualFileRenameUseCaseTest {
folderArgs.add(new String[]{baseName}); folderArgs.add(new String[]{baseName});
return new ResolvedTargetFilename(baseName); return new ResolvedTargetFilename(baseName);
} }
@Override public void tryDeleteTargetFile(String name) { } @Override public void tryDeleteTargetFile(String name) {
// intentionally empty
}
}; };
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase( DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
@@ -616,11 +636,21 @@ class DefaultManualFileRenameUseCaseTest {
/** Führt keine Persistenzoperationen durch. */ /** Führt keine Persistenzoperationen durch. */
private static class NoOpTransactionOperations implements UnitOfWorkPort.TransactionOperations { private static class NoOpTransactionOperations implements UnitOfWorkPort.TransactionOperations {
@Override public void saveProcessingAttempt(ProcessingAttempt attempt) { } @Override public void saveProcessingAttempt(ProcessingAttempt attempt) {
@Override public void createDocumentRecord(DocumentRecord record) { } // intentionally empty
@Override public void updateDocumentRecord(DocumentRecord record) { } }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } @Override public void createDocumentRecord(DocumentRecord record) {
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { } // intentionally empty
}
@Override public void updateDocumentRecord(DocumentRecord record) {
// intentionally empty
}
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// intentionally empty
}
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
// intentionally empty
}
} }
/** Zeichnet updateDocumentRecord-Aufrufe auf. */ /** Zeichnet updateDocumentRecord-Aufrufe auf. */
@@ -631,10 +661,18 @@ class DefaultManualFileRenameUseCaseTest {
this.captured = captured; this.captured = captured;
} }
@Override public void saveProcessingAttempt(ProcessingAttempt attempt) { } @Override public void saveProcessingAttempt(ProcessingAttempt attempt) {
@Override public void createDocumentRecord(DocumentRecord record) { } // intentionally empty
}
@Override public void createDocumentRecord(DocumentRecord record) {
// intentionally empty
}
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); } @Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { } // intentionally empty
}
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
// intentionally empty
}
} }
} }
@@ -179,11 +179,21 @@ class DefaultResetDocumentStatusUseCaseTest {
private static ProcessingLogger noOpLogger() { private static ProcessingLogger noOpLogger() {
return new ProcessingLogger() { return new ProcessingLogger() {
@Override public void info(String msg, Object... args) { } @Override public void info(String msg, Object... args) {
@Override public void debug(String msg, Object... args) { } // intentionally empty
@Override public void debugSensitiveAiContent(String msg, Object... args) { } }
@Override public void warn(String msg, Object... args) { } @Override public void debug(String msg, Object... args) {
@Override public void error(String msg, Object... args) { } // intentionally empty
}
@Override public void debugSensitiveAiContent(String msg, Object... args) {
// intentionally empty
}
@Override public void warn(String msg, Object... args) {
// intentionally empty
}
@Override public void error(String msg, Object... args) {
// intentionally empty
}
}; };
} }
@@ -202,15 +212,21 @@ class DefaultResetDocumentStatusUseCaseTest {
@Override @Override
public void saveProcessingAttempt( public void saveProcessingAttempt(
de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt attempt) { } de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt attempt) {
// intentionally empty
}
@Override @Override
public void createDocumentRecord( public void createDocumentRecord(
de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { } de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) {
// intentionally empty
}
@Override @Override
public void updateDocumentRecord( public void updateDocumentRecord(
de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { } de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) {
// intentionally empty
}
@Override @Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {

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