187 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fix: beide Module explizit in <addModules> aufgenommen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Alle 323 Tests bestanden.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Neue Farblogik in appendSummary() direkt auf failedCount basieren.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

mvn clean verify erfolgreich bestanden

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

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

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

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

Neu: REPROCESS_RESET_FAILED_HINT mit erklärender Meldung.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:26:21 +02:00
366 changed files with 45776 additions and 2442 deletions
+3
View File
@@ -3,6 +3,8 @@
# =========================================================
**/target/
dependency-reduced-pom.xml
# Generierte Flat-POM-Dateien des flatten-maven-plugin (CI-friendly Versioning)
**/.flattened-pom.xml
# =========================================================
# Eclipse / IDE
@@ -75,3 +77,4 @@ replay_pid*
/run-milestone.ps1
/run-v11.ps1
.m2repo
/start-headless.bat
+53 -9
View File
@@ -11,9 +11,18 @@ Ab V2.0 wird die Anwendung um eine **lokale JavaFX-Desktop-GUI** erweitert. Die
@docs/specs/meilensteine-v2_0.md
Für die Umsetzung ist zusätzlich immer das aktuell aktive Arbeitspaket unter `docs/workpackages/` maßgeblich.
Dateinamensschema: `M9 - Arbeitspakete.md`, `M10 - Arbeitspakete.md`, … `M13 - Arbeitspakete.md`
Dateinamensschema: `M9 - Arbeitspakete.md`, `M10 - Arbeitspakete.md`, … `M13 - Arbeitspakete.md`, `M14_-_Arbeitspakete.md`, `M15_-_Arbeitspakete.md`.
Nicht raten, wenn Dokumente fehlen, unklar sind oder sich widersprechen.
## Modulare Architektur-Übersichten
Detailwissen über Pakete, Schlüsselklassen, Ports und Bootstrap-Verdrahtung ist in drei modularen Übersichtsdokumenten unter `docs/architecture/` ausgelagert. Wer in einem bestimmten Modul arbeitet, liest diese Datei zusätzlich zu CLAUDE.md:
- `docs/architecture/domain-overview.md` `pdf-umbenenner-domain` und `pdf-umbenenner-application`: Domänenmodell, Inbound- und Outbound-Ports, Application-Services.
- `docs/architecture/gui-overview.md` `pdf-umbenenner-adapter-in-gui`: Workspace-/Tab-Struktur, View-Modelle, GUI-interne Ports, JavaFX-Threading-Modell.
- `docs/architecture/adapter-overview.md` `pdf-umbenenner-adapter-out`, `pdf-umbenenner-adapter-in-cli`, `pdf-umbenenner-bootstrap`: konkrete Outbound-Adapter, CLI-Einstiegspunkt, Verdrahtungslogik und Provider-Auswahl.
Für Arbeit ausschließlich in einem dieser Bereiche genügt CLAUDE.md plus die jeweils passende Übersichtsdatei.
## Priorisierung der Regeln
Die Dokumente haben folgende feste Bedeutung:
@@ -47,8 +56,8 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
- `--config <pfad>` steht für GUI und headless zur Verfügung
- kein Webserver
- kein Applikationsserver
- keine Dauerlauf-Anwendung
- kein interner Scheduler
- keine Dauerlauf-Anwendung (Ausnahme: GUI-Modus mit aktivem Scheduler, s. Scheduler-Ausnahme)
- kein interner Scheduler (Ausnahme: optionaler GUI-Scheduler ab V3.2, s. Scheduler-Ausnahme)
- das Shade-JAR bleibt das primäre Distributionsartefakt
- zusätzlicher nativer Windows-Installer (MSI) ab V3.0 via Maven-Profil `release` (jpackage, WiX Toolset 3.x im PATH erforderlich); der Normalbuild `mvn clean verify` bleibt vom Profil unberührt und benötigt kein WiX
- Log4j2 für Logging
@@ -68,9 +77,28 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
- `pdf-umbenenner-application`
- `pdf-umbenenner-adapter-in-cli`
- `pdf-umbenenner-adapter-in-gui`
- `pdf-umbenenner-adapter-in-scheduler`
- `pdf-umbenenner-adapter-out`
- `pdf-umbenenner-bootstrap`
## Scheduler-Ausnahme (ab V3.2)
Ab V3.2 enthält der GUI-Modus einen optionalen internen Scheduler, der periodisch
automatische Verarbeitungsläufe anstößt. Die folgenden Regeln gelten abweichend von
den allgemeinen Leitplanken:
- Der Scheduler ist **ausschließlich im GUI-Modus** verfügbar. Im headless Betrieb werden
`scheduler.enabled` und `scheduler.interval.seconds` vollständig ignoriert.
- Das Modul `pdf-umbenenner-adapter-in-scheduler` erfüllt eine gemischte Rolle als
technischer Treiber und Adapter. Dies ist ein bewusster Architekturkompromiss, kein
Architekturbruch.
- `pdf-umbenenner-adapter-in-scheduler` enthält **kein JavaFX**.
- **Kein WatchService:** Der Scheduler löst reguläre Verarbeitungsläufe periodisch aus;
er nutzt keinen Dateisystem-Event-Mechanismus.
- Das bestehende Datenbankschema bleibt in V3.2 unverändert; keine
Scheduler-spezifische Schemaerweiterung.
- Token- und Kostentracking sind nicht Bestandteil von V3.2.
## Architekturregeln
- Strikte **hexagonale Architektur / Ports and Adapters**
- Abhängigkeiten zeigen immer **nach innen**
@@ -121,9 +149,9 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
## Globale fachliche Leitplanken
- Zielformat: `YYYY-MM-DD - Titel.pdf`
- Bei Namenskollisionen: `YYYY-MM-DD - Titel(1).pdf`, `YYYY-MM-DD - Titel(2).pdf`, ...
- Die **20 Zeichen** gelten nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit
- Die **konfigurierte maximale Titellänge** gilt nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit
- Das Dubletten-Suffix wird unmittelbar vor `.pdf` angehängt
- Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen
- Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen, Bindestrichen, Punkten, Kommas und Ampersands
- Eigennamen bleiben unverändert
- Datumsermittlung mit Priorität aus den fachlichen Anforderungen; wenn kein belastbares Datum eindeutig ableitbar ist, ist das **aktuelle Datum** als Fallback erlaubt
- Mehrdeutige Dokumente liefern **kein** unsicheres Ergebnis, sondern einen Fehler
@@ -136,9 +164,15 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
## Aktiver Implementierungsstand
V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben.
Der aktive Entwicklungsstand ist **V2.0**. Ziel ist der Ausbau um eine lokale JavaFX-Desktop-GUI als neuen Standardstart, ohne die bestehende Architektur, das Standalone-JAR-Betriebsmodell oder den headless Scheduler-Betrieb aufzugeben.
Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technische Tests) ist abgeschlossen.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt in V2.0 unverändert.
**V2.9 ist abgeschlossen.** Der Tab „Verarbeitungslauf" wurde erweitert um eine integrierte PDF-Vorschau (Lazy-Rendering direkt über PDFBox, In-Memory-Cache, Seitennavigation) sowie einen editierbaren Dateiname-Bereich mit Live-Validierung, Dirty-State-Dialog und atomarer Dateisystem-/DB-Transaktion inklusive Rollback und Fingerprint-basierter Konfliktauflösung. Die zugehörigen neuen Ports, Use Cases und Adapter sind in den modularen Architektur-Übersichten beschrieben.
Verhaltensänderungen seit V2.9: Die GUI startet maximiert, und die zuletzt geladene Konfigurationsdatei wird beim Start automatisch wieder geladen; existiert sie nicht mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
**V3.2 ist abgeschlossen.** Der GUI-Modus wurde um einen optionalen automatischen Scheduler erweitert (neuer Tab „Scheduler"). Der Scheduler startet periodisch Verarbeitungsläufe; Intervall und Autostart sind konfigurierbar. Während der Scheduler aktiv ist, sind der Konfigurations-Tab und das manuelle Starten von Läufen gesperrt. Im headless Betrieb werden Scheduler-Parameter vollständig ignoriert.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert.
## Statussemantik
@@ -227,6 +261,13 @@ Bestehende Kommentare mit solchen Bezeichnern, die durch eigene Änderungen ber
- Keine stillen Änderungen am bestehenden headless Batch-Betrieb
- GUI-Code darf den headless Pfad nicht unnötig früh initialisieren
## Commit und Push nach jeder Implementierung
Nach jeder Implementierung oder Dateiänderung wird ein Commit auf `main` erstellt und gepusht:
1. Geänderte Dateien stagen und committen
2. `git push origin main` ausführen
3. Schlägt der Push mit einem AUTH-Fehler fehl: 1 Sekunde warten, dann genau **einen** weiteren Versuch unternehmen
4. Schlägt auch der zweite Versuch fehl: Fehler benennen, keinen weiteren automatischen Retry
## Definition of Done pro Arbeitspaket
Ein Arbeitspaket ist erst fertig, wenn:
- der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist
@@ -276,10 +317,13 @@ Verbindlich zweckmäßige Parameter:
- `max.retries.transient` max. historisierte transiente Fehlversuche pro Fingerprint (**Integer >= 1**, `0` ist ungültig)
- `max.pages` Seitenlimit
- `max.text.characters` maximale Zeichenzahl für KI-Eingabe
- `max.title.length` maximale Länge des Basistitels in Zeichen (gültiger Bereich 10..120, Default 60)
- `prompt.template.file` externe Prompt-Datei
- `log.ai.sensitive` sensible KI-Logausgabe freischalten (Boolean, Default: `false`)
- `runtime.lock.file` Lock-Datei (optional)
- `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:
@@ -309,7 +353,7 @@ Verbindlicher Ablauf:
6. Erst danach den normalen Lauf fortsetzen
## Nicht-Ziele / Verbote
- kein manueller Verarbeitungslauf aus der GUI (erst V2.1+)
- kein manueller Verarbeitungslauf aus der GUI (kein vollständiger Lauf; Bearbeitungen nach Lauf sind zulässig)
- kein DB-/Historien-Tab in der GUI (erst V2.x+)
- kein Kosten-Tracking (erst V2.x+)
- kein echter Mini-KI-Testaufruf mit fachlicher Antwortauswertung
@@ -318,7 +362,7 @@ Verbindlicher Ablauf:
- keine OCR innerhalb der Java-Anwendung
- keine DMS-Funktionalität
- kein menschlicher Review-Workflow in der Anwendung
- keine interne Scheduler-Logik
- keine interne Scheduler-Logik außerhalb des optionalen GUI-Schedulers (s. Scheduler-Ausnahme)
- keine Architekturbrüche
- keine neuen Bibliotheken oder Frameworks ohne klare Notwendigkeit und Begründung
- **keine** automatische Fallback-Umschaltung zwischen KI-Providern
Vendored
+194
View File
@@ -0,0 +1,194 @@
// Jenkins-Pipeline für den PDF KI Renamer
// Läuft auf einem Linux-Container (Synology NAS).
// Der MSI-Build ist Windows-only (jpackage + WiX Toolset 3.x). Jenkins läuft im
// Linux-Container auf Synology NAS und kann kein MSI erzeugen. Der MSI-Build
// wird bewusst manuell auf der Windows-Entwicklungsmaschine ausgeführt:
// .\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests
pipeline {
agent any
options {
disableConcurrentBuilds()
}
tools {
maven 'maven-3'
}
// MAJOR und MINOR werden manuell als Jenkins-Parameter gepflegt.
// BUILD_NUMBER wird automatisch durch Jenkins vergeben.
// Die resultierende Versionsnummer lautet: MAJOR.MINOR.BUILD_NUMBER
parameters {
string(name: 'MAJOR', defaultValue: '3', description: 'SemVer MAJOR (manuell)')
string(name: 'MINOR', defaultValue: '0', description: 'SemVer MINOR (manuell)')
}
stages {
stage('Version bestimmen') {
steps {
script {
def isManual = !currentBuild.getBuildCauses('hudson.model.Cause$UserIdCause').isEmpty()
def jenkinsHome = env.JENKINS_HOME ?: '/var/jenkins_home'
def safeJobName = env.JOB_NAME.replaceAll(/[^A-Za-z0-9._-]/, '_')
def stateDir = "${jenkinsHome}/version-state"
def stateFile = "${stateDir}/${safeJobName}.properties"
if (isManual) {
env.EFFECTIVE_MAJOR = params.MAJOR
env.EFFECTIVE_MINOR = params.MINOR
sh """
mkdir -p '${stateDir}'
cat > '${stateFile}' <<'EOF'
MAJOR=${params.MAJOR}
MINOR=${params.MINOR}
EOF
"""
echo "Manueller Build erkannt. Version gespeichert: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
} else {
def stateExists = (sh(script: "[ -f '${stateFile}' ]", returnStatus: true) == 0)
if (stateExists) {
env.EFFECTIVE_MAJOR = sh(
script: "grep '^MAJOR=' '${stateFile}' | cut -d= -f2-",
returnStdout: true
).trim()
env.EFFECTIVE_MINOR = sh(
script: "grep '^MINOR=' '${stateFile}' | cut -d= -f2-",
returnStdout: true
).trim()
echo "Automatischer Build erkannt. Gespeicherte Version verwendet: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
} else {
env.EFFECTIVE_MAJOR = params.MAJOR
env.EFFECTIVE_MINOR = params.MINOR
echo "Automatischer Build ohne gespeicherten Stand. Fallback auf Parameter: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
}
}
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
}
}
} // stage: Version bestimmen
stage('Maven Build') {
steps {
catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') {
// -Drevision übergibt die vollständige Versionsnummer an Maven.
// Das flatten-maven-plugin im Parent-POM löst ${revision} in
// allen installierten POMs auf.
sh "mvn clean verify -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER}"
}
}
} // stage: Maven Build
stage('SonarQube Analyse') {
steps {
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
withSonarQubeEnv('SonarQube') {
sh "mvn sonar:sonar -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} -Dsonar.projectKey=pdf-umbenenner -Dsonar.projectName='PDF KI Renamer'"
}
}
}
} // stage: SonarQube Analyse
stage('Publish PIT Coverage') {
steps {
recordCoverage(
tools: [[
parser: 'PIT',
pattern: '**/target/pit-reports/mutations.xml'
]],
id: 'pit',
name: 'PIT Mutation Coverage',
failOnError: true
)
}
} // stage: Publish PIT Coverage
stage('Archive JAR') {
steps {
// Bash wird explizit erzwungen, weil Jenkins-Agenten standardmäßig
// sh (dash) verwenden, das kein mapfile kennt. mapfile zählt exakt
// die gefundenen Shade-JARs und bricht ab, wenn nicht genau eines vorhanden ist.
sh '''#!/usr/bin/env bash
set -euo pipefail
mapfile -t JARS < <(find pdf-umbenenner-bootstrap/target \
-maxdepth 1 -name "pdf-umbenenner-bootstrap-*.jar" \
! -name "*-sources.jar" ! -name "*-javadoc.jar")
test "${#JARS[@]}" -eq 1 \
|| { echo "FEHLER: Erwartet genau 1 Shade-JAR, gefunden: ${#JARS[@]}"; exit 1; }
JAR_NAME="pdf-ki-renamer-${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}.jar"
cp "${JARS[0]}" "$JAR_NAME"
echo "Shade-JAR archiviert als: $JAR_NAME"
'''
archiveArtifacts artifacts: 'pdf-ki-renamer-*.jar', fingerprint: true
}
} // stage: Archive JAR
stage('Artefakt ablegen') {
steps {
sh '''#!/usr/bin/env bash
set -euo pipefail
BUILD_DIR="/builds/${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}"
mkdir -p "$BUILD_DIR"
cp pdf-ki-renamer-*.jar "$BUILD_DIR/"
echo "Artefakt abgelegt unter: $BUILD_DIR"
'''
}
} // stage: Artefakt ablegen
stage('Berichte veröffentlichen') {
steps {
junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true
recordCoverage(
tools: [[parser: 'JACOCO', pattern: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate/jacoco.xml']],
enabledForFailure: true
)
publishHTML(target: [
reportName: 'JaCoCo HTML Report',
reportDir: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate',
reportFiles: 'index.html',
keepAll: true,
alwaysLinkToLastBuild: true,
allowMissing: true
])
}
} // stage: Berichte veröffentlichen
stage('Aufräumen') {
steps {
sh '''#!/usr/bin/env bash
set -euo pipefail
rm -f pdf-ki-renamer-*.jar
echo "Aufräumen abgeschlossen."
'''
}
} // stage: Aufräumen
} // stages
post {
success {
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} erfolgreich abgeschlossen."
}
failure {
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} fehlgeschlagen."
}
always {
deleteDir()
}
}
} // pipeline
+10 -5
View File
@@ -4,8 +4,10 @@ Ein lokal gestartetes Java-Programm zur KI-gestützten Umbenennung bereits OCR-v
Die Anwendung liest PDF-Dateien aus einem konfigurierbaren Quellordner, extrahiert den Text, ermittelt daraus per KI einen normierten Dateinamen und legt **eine Kopie** im Zielordner ab. Die Quelldateien bleiben unverändert.
> **V2.0:** Die Anwendung enthält ab V2.0 eine lokale JavaFX-Desktop-GUI als Standardstart.
> Die GUI dient der Konfiguration, Validierung und technischen Diagnose.
> **V2.9:** Die Anwendung enthält eine lokale JavaFX-Desktop-GUI als Standardstart.
> Die GUI dient der Konfiguration, Validierung, technischen Diagnose und der Ausführung von Verarbeitungsläufen.
> Der Tab „Verarbeitungslauf" enthält eine integrierte PDF-Vorschau und einen editierbaren Dateiname-Bereich.
> Die GUI startet maximiert und lädt beim Start automatisch die zuletzt verwendete Konfigurationsdatei.
> Der headless Batch-Betrieb bleibt über `--headless` vollständig erhalten.
> Details zum Betrieb: [`docs/betrieb.md`](docs/betrieb.md)
@@ -59,8 +61,8 @@ YYYY-MM-DD - Titel(2).pdf
Wichtige Regeln:
- die **20 Zeichen** beziehen sich nur auf den **Basistitel**
- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen
- die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel**
- das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge
- Titel werden auf **Deutsch** erzeugt
- Eigennamen bleiben unverändert
- Quelldateien werden **nie** überschrieben, verschoben oder verändert
@@ -118,6 +120,7 @@ Typische Bereiche sind:
- Timeout
- Seitenlimit
- Textlimit für KI-Aufrufe
- maximale Titellänge (`max.title.length`, Default 60, Bereich 10..120)
- Prompt-Datei
- Logging
@@ -211,7 +214,7 @@ Empfohlene Leserichtung:
## Status des Projekts
Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der aktuelle Produktstand (V2.0) baut auf einem vollständig implementierten Kern für:
Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der aktuelle Produktstand baut auf einem vollständig implementierten Kern für:
- Konfiguration und Startvalidierung
- Quellordner-Scan und PDF-Textauslese
@@ -220,6 +223,8 @@ Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der a
- Dateinamensbildung und Zielkopie
- Retry-Logik, Logging und betriebliche Robustheit
- JavaFX-Desktop-GUI als Standardstart (Konfigurationseditor, Validierung, technische Tests)
- Tab „Verarbeitungslauf" mit integrierter PDF-Vorschau pro Zeile und editierbarem Dateiname-Bereich
- Atomare Dateisystem- und Datenbankoperationen für manuelle Umbenennungen mit Konfliktauflösung
- headless Batch-Betrieb über `--headless` (rückwärtskompatibel zu V1.x)
## Lizenz / Nutzung
+4 -1
View File
@@ -26,7 +26,10 @@ max.retries.transient=3
max.pages=10
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
max.text.characters=5000
max.text.characters=1000
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
max.title.length=60
# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator
# in der Versuchshistorie.
@@ -13,6 +13,8 @@ sqlite.file=./work/test/pdf-umbenenner-test.db
max.retries.transient=1
max.pages=5
max.text.characters=2000
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
max.title.length=60
prompt.template.file=./config/prompts/template.txt
# ---------------------------------------------------------------------------
+38 -8
View File
@@ -2,9 +2,35 @@ Du bist ein Assistent zur automatischen Benennung gescannter PDF-Dokumente.
Analysiere den folgenden Dokumenttext und ermittle:
1. Einen inhaltlich passenden deutschen Titel (maximal 20 Zeichen, nur Buchstaben und Leerzeichen, keine Abkürzungen, keine generischen Bezeichnungen wie "Dokument", "Datei", "Scan" oder "PDF")
1. Einen inhaltlich passenden deutschen Titel nach dem Schema: {Absender} {Betreff_gekürzt}
2. Das relevanteste Datum des Dokuments
Titelschema verbindlich:
- Erster Teil: Absender (Person, Firma, Behörde, Institution) so wie im Dokument genannt, Abkürzungen wie GmbH, AG, KfW, Kfz sind erlaubt
- Zweiter Teil: Betreff oder Kernaussage des Dokuments, so kurz wie möglich bevorzugt aus einer vorhandenen Betreffzeile, sonst aus dem Dokumentinhalt abgeleitet
- Beide Teile durch ein Leerzeichen getrennt, kein Sonderzeichen außer Bindestrich und Leerzeichen
- **Maximal {MAX_TITLE_LENGTH} Zeichen gesamt diese Grenze ist nicht verhandelbar und MUSS eingehalten werden**
- Keine generischen Begriffe wie "Dokument", "Datei", "Scan", "PDF", "Schreiben", "Brief"
- Titel auf Deutsch formulieren
WICHTIG Längenbegrenzung ist deine Verantwortung:
Wenn ein idealer Titel länger als {MAX_TITLE_LENGTH} Zeichen wäre, darfst und musst du ihn selbst kürzen. Optionen:
- Betreff verkürzen (z.B. "Steuerbescheid 2024" statt "Einkommensteuerbescheid 2024")
- Unwesentliche Details weglassen
- Absender mit Standard-Abkürzung darstellen
- Absender weglassen und nur Betreff nutzen, falls sinnvoll
Liefere IMMER einen Titel, der das Zeichenlimit einhält. Niemals einen, der es überschreitet.
Beispiele für gute Titel:
- Stadtwerke Bochum Grundbesitzabgaben 2025
- Allianz Versicherung Kfz-Nachtrag Polo
- Finanzamt Bochum Steuerbescheid 2024
- KfW Förderbescheid Energieeffizienz
Beispiele für Kürzung bei Längenlimit:
- zu lang: "Versicherungsgesellschaft Allianz Versicherung AG Kfz-Versicherungsnachtrag Volkswagen Polo" → gekürzt: "Allianz Kfz-Nachtrag Polo"
- zu lang: "Bundesfinanzbehörde Finanzamt Bochum Bescheid zur Einkommensteuer Veranlagung" → gekürzt: "Finanzamt Bochum Steuerbescheid"
Datumsermittlung nach Priorität:
- Rechnungsdatum
- Dokumentdatum
@@ -12,11 +38,15 @@ Datumsermittlung nach Priorität:
- Schreibdatum oder Ende eines Leistungszeitraums
- Kein Datum angeben, wenn kein belastbares Datum eindeutig ableitbar ist
Titelregeln:
- Titel auf Deutsch formulieren
- Eigennamen (Personen, Firmen, Orte) unverändert übernehmen
- Maximal 20 Zeichen (nur der Basistitel, ohne Datumspräfix)
- Keine Sonderzeichen außer Leerzeichen
- Eindeutig und verständlich, nicht generisch
Wenn das Dokument nicht eindeutig interpretierbar ist, beschreibe dies im Reasoning.
**Ausgabeformat: Ausschließlich reines JSON-Objekt**
Antworte nur mit einem JSON-Objekt nach folgendem Schema:
- Keine Präambel, keine Erklärungen, keine Markdown-Codeblöcke
- `title` (erforderlich): Der ermittelte deutsche Titel nach obigem Schema
- `reasoning` (erforderlich): Absender und Betreff in je einem Satz begründen
- `date` (optional): Das ermittelte Datum im Format YYYY-MM-DD; auslassen, falls kein belastbares Datum ableitbar ist
Beispiel:
{"title":"Stadtwerke Bochum Grundbesitzabgaben 2025","reasoning":"Absender ist Stadtwerke Bochum laut Briefkopf. Betreff ist die Jahresabrechnung der Grundbesitzabgaben 2025.","date":"2025-03-15"}
+168
View File
@@ -0,0 +1,168 @@
# AP-A Token-Tracking Fundament Zusammenfassung
Dieses Dokument fasst alle Klassen, Methoden und Dateien zusammen, die im Zuge
von AP-A (Token- und Kosten-Tracking-Fundament der V3.3-Spezifikation, #74)
neu erstellt oder substanziell erweitert wurden.
## Schema-Migration
- `pdf-umbenenner-adapter-out/src/main/resources/db/migration/V2__token_tracking.sql`
- Sechs neue Spalten in `processing_attempt`:
`input_tokens`, `output_tokens`,
`cache_creation_input_tokens`, `cache_read_input_tokens`,
`price_input_per_token_nano_usd`, `price_output_per_token_nano_usd`.
- Neue Tabelle `model_price` mit Composite Primary Key
`(provider, model_name)`, NOT-NULL-Preisen, Currency-CHECK auf `'USD'`,
`updated_at`-Spalte.
- Zwei zusätzliche Indizes auf `processing_attempt`:
`idx_processing_attempt_started_at_provider_fp_model`,
`idx_processing_attempt_run_id_provider_model`.
- Default-Preise für gpt-4o-mini, gpt-4o, gpt-4.1*, gpt-5*, claude-haiku-4-5,
claude-sonnet-4-6 und claude-opus-4-7 (`ON CONFLICT DO NOTHING`).
## Application-Modul
### DTOs (`application/dto`)
- `AiUsageMetadata` Token-Verbrauchsmetadaten mit `empty()`,
`hasAnyTokenData()`, `hasCacheTokens()`.
- `ModelPriceEntry` Schreib-/Validierungs-DTO mit Wertgrenzen-Validierung im
Konstruktor.
- `ModelPriceView` Lese-/Anzeige-DTO mit nullable `updatedAt` und
`invalidUpdatedAt`-Flag.
- `ModelPriceKey` Composite-Key für Löschungen.
- `ModelPriceChangeSet` atomarer Block aus Upserts und Deletions, defensive
Listen-Kopie.
### Cost-Komponenten (`application/cost`)
- `CostResult` interpretierte Kosten-Anzeige mit Status-Flags.
- `CostCalculator` `formatRow(...)` und `calculateAttempt(...)` (echt
implementiert), `formatTotal(...)` als Stub für AP-B.
### Ports (`application/port/out`)
- `ModelPriceRepository` `findAll`, `findByProviderAndModelName`, `upsert`,
`delete`, `saveAllChanges`.
- `AiInvocationSuccess` (erweitert) neues Feld `usageMetadata`.
### Use Cases (`application/usecase`)
- `DefaultManageModelPricesUseCase` CRUD-Fassade mit ChangeSet-Konflikt-
validierung (vier Regeln) und Provider-Whitelist beim Upsert.
- `ModelPriceValidationException` deutsche Validierungsfehler-Exception.
### Application-Service-Anpassungen
- `AiNamingService` (erweitert) reicht `AiUsageMetadata` aus dem
`AiInvocationSuccess` als Token-Felder in den `AiAttemptContext` weiter.
- `DocumentProcessingCoordinator` (erweitert)
- neuer optionaler Konstruktor mit `ModelPriceRepository` und
`headlessMode`-Flag.
- `loadPriceSnapshot(modelName)` lädt Snapshot-Preis pro Versuch; Lookup-
Fehler liefern leeren Snapshot ohne Attempt-Verlust.
- `buildAttempt(...)` befüllt jetzt Token- und Preis-Snapshot-Felder im
`ProcessingAttempt`.
### Domain-Anpassungen
- `AiAttemptContext` (erweitert) vier nullable Token-Felder
(`inputTokens`, `outputTokens`, `cacheCreationInputTokens`,
`cacheReadInputTokens`); Backward-compatible Convenience-Konstruktor.
- `ProcessingAttempt` (erweitert) sechs nullable Token-/Preis-Snapshot-
Felder; Convenience-Konstruktor und `withoutAiFields(...)` ohne Verhaltens-
änderung.
## Adapter-Out-Modul
- `SqliteConnectionFactory` (neu) zentrale Connection-Factory; setzt
`PRAGMA journal_mode=WAL` und `PRAGMA busy_timeout=5000`.
Foreign-Key-Pragma wird bewusst nicht implizit gesetzt (Verhalten der
bisherigen `DriverManager.getConnection`-Stellen erhalten).
- `SqliteUnitOfWorkAdapter`, `SqliteProcessingAttemptRepositoryAdapter`,
`SqliteHistoryQueryAdapter`, `SqliteDocumentRecordRepositoryAdapter`
(jeweils geändert) nutzen die neue Factory.
- `SqliteProcessingAttemptRepositoryAdapter.save()` (erweitert)
INSERT um sechs neue Spalten erweitert, neue Hilfsmethode
`setNullableLong(...)`.
- `SqliteHistoryQueryAdapter.mapToProcessingAttempt(...)` (erweitert)
liest die sechs neuen Spalten via `readNullableLong(...)`.
- `SqliteSchemaInitializationAdapter` (geändert) erwartete Spalten/Indizes
bleiben am V1-Zielschema; Doc-Klarstellung, dass V2 additiv auf der Baseline
arbeitet.
- `SqliteModelPriceRepositoryAdapter` (neu) `findAll`,
`findByProviderAndModelName`, `upsert`, `delete`, `saveAllChanges`
(UPSERT via `ON CONFLICT(provider, model_name) DO UPDATE`, transaktionaler
Batch). Lese-Mapping behandelt `DateTimeParseException` als
`invalidUpdatedAt`.
- `ModelPriceRepositoryException` (neu) technischer JDBC-Fehler.
### KI-Adapter
- `AnthropicClaudeHttpAdapter` (geändert) neue Methode
`extractTokenUsageFromResponse(JSONObject)` für `usage.input_tokens`,
`usage.output_tokens`, `usage.cache_creation_input_tokens`,
`usage.cache_read_input_tokens` mit Validierung (negativ, > 10 Mio.,
nicht-numerisch → NULL + WARN).
- `OpenAiHttpAdapter` (geändert) analoge Methode mit Mapping
`prompt_tokens → input_tokens`, `completion_tokens → output_tokens`;
Cache-Felder bleiben null.
## GUI-Modul
### Neuer Tab "Modell-Preise"
- `adapter-in-gui/modelprices/GuiModelPriceManagementPort` (neu)
Bridge-Port für GUI-Zugriff auf Modell-Preise.
- `adapter-in-gui/modelprices/GuiModelPricesTab` (neu) TableView mit
editierbaren Preisspalten (`In/1M USD`, `Out/1M USD`), Lösch-Button mit
Bestätigungsdialog, Add-Dialog mit Provider-Auswahl, Speichern-Aktion über
`ModelPriceChangeSet`. Konvertierung Nano-USD ↔ `$/1M Tokens` mit
HALF-UP-Rundung; unbekannte Provider werden read-only mit Tooltip
angezeigt; `updatedAt = null` als "ungueltig".
### Anbindung im Workspace
- `GuiConfigurationEditorWorkspace` (geändert) sechster Tab "Modell-
Preise" wird angelegt; neue Methode `warnIfActiveModelHasNoPriceEntry()`
zeigt vor dem Speichern eine deutsche Warnung an, wenn das aktuell
ausgewählte Modell keinen Preis-Eintrag besitzt.
- `GuiStartupContext` (geändert) neues optionales Feld
`modelPriceManagementPort` mit Backward-Kompatibilität.
- `BootstrapRunner` (geändert) neue Methode
`buildGuiModelPriceManagementPort()` und Helfer für die Verdrahtung;
Coordinator wird mit `ModelPriceRepository` und `headlessMode`-Flag
versorgt.
### History-Tab
- `GuiHistoryTab` (geändert) drei zusätzliche Spalten in der
Versuchstabelle: Input-Tokens, Output-Tokens, Kosten. Cache-only-Versuche
zeigen "nur Cache-Tokens, keine Standardkosten"; fehlender Preis-Snapshot
führt zu "Preis fehlt"; Mikrobeträge als "< $0.0001"; Cache-Beteiligung
ergänzt Suffix "(ohne Cache-Anteil)".
### Summary-Banner
- `BatchRunSummaryBanner` (geändert) aus einzeiliger HBox wurde eine
vierzeilige VBox: Status-Zeile, Token-Zeile, Kosten-Zeile, optionale
Cache-only-Zeile. Neue Record-Klasse `BatchRunTokenSummary` mit
`empty()`-Default; bestehende `update(Map)`-Aufrufer bleiben funktionsfähig.
## Testanpassungen
- `pdf-umbenenner-application/.../service/AiNamingServiceTest` und
`pdf-umbenenner-bootstrap/.../e2e/StubAiInvocationPort` alte
`AiInvocationSuccess`-Konstruktoraufrufe um `AiUsageMetadata.empty()`
ergänzt.
- `SqliteSchemaInitializationAdapterTest.fall1_leereDb_processingAttemptHatAlleErwartetenSpalten`
prüft jetzt zusätzlich die sechs Token-/Preis-Spalten.
- `GuiAdapterSmokeTest.editorWorkspace_startStateShowsEmptyHeaderDefaultsAndOneTab`
erwartet jetzt sechs Tabs inkl. "Modell-Preise".
## Build und Verifizierung
- `mvn clean verify` läuft auf dem Reactor `pdf-umbenenner-parent` durch
(Tests grün auf allen Modulen).
- Commit `08ec021` auf `main` gepusht.
## Bewusst ausgesparte Bereiche (für AP-B / AP-C)
- `CostCalculator.formatTotal(...)` ist ein Stub und wirft
`UnsupportedOperationException`.
- `TokenStatisticsReadModelPort`, `QueryCostAnalysisFullUseCase`,
`QueryCostAnalysisHeaderOnlyUseCase`, `QueryRunSummaryUseCase`,
`SqliteTokenStatisticsReadModelAdapter` sind nicht enthalten.
- Summary-Banner zeigt aktuell `0/0` Tokens und `$0.0000` Kosten, da das
Read-Model erst in AP-B verdrahtet wird.
- CLI-Befehle für Modell-Preise (#99) und Modell-Combobox-Filter (#98)
sind AP-C.
+356
View File
@@ -0,0 +1,356 @@
# Architektur-Übersicht: Adapter-Out, CLI & Bootstrap
Diese Datei beschreibt die drei Module `pdf-umbenenner-adapter-out`, `pdf-umbenenner-adapter-in-cli`
und `pdf-umbenenner-bootstrap`: ihren Zweck, ihre Paketstruktur, die wichtigsten Klassen und die
Verdrahtungslogik beim Programmstart. Sie richtet sich an Entwickler, die in einem dieser Module
arbeiten wollen und noch keinen Überblick über das Projekt haben. Domain- und Application-Schicht
(Port-Verträge, fachliche Domänenobjekte, Use-Case-Interfaces) sind nicht Gegenstand dieses
Dokuments sie sind in `docs/architecture/domain-overview.md` beschrieben. GUI-interne Ports und
die Struktur des GUI-Adapters finden sich in `docs/architecture/gui-overview.md`. Die hexagonale
Abhängigkeitsrichtung ist strikt: Adapter kennen Domain und Application, nicht umgekehrt. Adapter
dürfen außerdem nicht direkt voneinander abhängen.
---
## 1. Modulzweck
### pdf-umbenenner-adapter-out
Enthält alle Outbound-Adapter-Implementierungen, also die konkreten technischen Lösungen für
sämtliche Outbound-Ports der Application. Dazu gehören: Dateisystemzugriff, PDF-Textextraktion
via PDFBox, SQLite-Persistenz (Schema, Repositories, Unit of Work), HTTP-Clients für zwei
KI-Provider-Familien (OpenAI-kompatibel und Anthropic nativ), Properties-Konfiguration inklusive
Legacy-Migration, dateibasierter Run-Lock sowie Systemuhr und SHA-256-Fingerprint.
### pdf-umbenenner-adapter-in-cli
Schlanker Inbound-Adapter für den kopflosen Batch-Betrieb. Enthält genau eine Klasse
(`SchedulerBatchCommand`), die den CLI-Einstiegspunkt bildet und ausschließlich über das
Inbound-Port-Interface an die Application delegiert. Keine eigene Fachlogik.
### pdf-umbenenner-bootstrap
Composition Root der Anwendung. Verantwortlich für: CLI-Argument-Parsing,
Konfigurationsauflösung und -validierung, Aufbau des vollständigen Objektgraphen (manuell, ohne
DI-Framework), Auswahl der aktiven KI-Adapter-Implementierung, Dispatch auf GUI- oder
Headless-Pfad sowie Exit-Code-Ableitung. Bootstrap ist die einzige Stelle, an der alle Module
zusammengeführt werden.
---
## 2. Paketstruktur
### pdf-umbenenner-adapter-out
Wurzelpaket: `de.gecheckt.pdf.umbenenner.adapter.out`
| Unterpaket | Inhalt |
|-------------------------|-------------------------------------------------------------------------------------|
| `.ai` | HTTP-Adapter für OpenAI-kompatible Schnittstelle und Anthropic Messages API |
| `.clock` | Systemuhr-Adapter (`Instant.now()`) |
| `.configuration` | Properties-Laden, Multi-Provider-Parsing/-Validierung, Legacy-Migration |
| `.fingerprint` | SHA-256-Inhalts-Fingerprint |
| `.lock` | Dateibasierter Run-Lock |
| `.modelcatalog` | HTTP-Modellabruf für den GUI-Konfigurationseditor |
| `.pathcheck` | Pfadprüfung für den GUI-Editor |
| `.pdfextraction` | PDFBox-3.x-Adapter: Textextraktion und Seitenanzahl |
| `.prompt` | Prompt-Template-Lader |
| `.resourcecreation` | Anlegen von Ordnern und Dateien (korrigierende technische Tests) |
| `.sourcedocument` | Quellordner-Scanner (nicht rekursiv) |
| `.sqlite` | Schema-Initialisierung, Repositories, Unit of Work |
| `.targetcopy` | Zielkopie via Temp-Datei und atomarem Move |
| `.targetfolder` | Kollisionsfreier Zieldateiname, Umbenennung bestehender Zieldateien |
| `.validation` | API-Key-Auflösung aus Umgebungsvariablen (GUI-Editor) |
| `.bootstrap.validation` | `StartConfiguration`-Validierung vor Prozessstart |
### pdf-umbenenner-adapter-in-cli
Wurzelpaket: `de.gecheckt.pdf.umbenenner.adapter.in.cli`
Enthält ausschließlich `SchedulerBatchCommand` sowie die zugehörige `package-info.java`.
### pdf-umbenenner-bootstrap
Wurzelpaket: `de.gecheckt.pdf.umbenenner.bootstrap`
| Unterpaket | Inhalt |
|---------------------|-------------------------------------------------------------------------------------------------------|
| *(Wurzel)* | `PdfUmbenennerApplication` (main), `BootstrapRunner`, `AiProviderSelector` |
| `.adapter` | Bootstrap-interne Adapter: `Log4jProcessingLogger`, `GuiConfigurationPropertiesWriter`, `AiModelCatalogDispatcher` |
| `.singleinstance` | `SingleInstanceGuard` Einzelinstanz-Schutz via Loopback-ServerSocket |
| `.startup` | `StartupMode`, `StartupArguments`, `CliArgumentParser` |
---
## 3. Schlüsselklassen
Die folgenden Klassen sind für das Verständnis der drei Module zentral. FQN-Kürzel: `...` steht
jeweils für das Wurzelpaket des Moduls.
### Adapter-Out
#### KI-Adapter
- **`...ai.OpenAiHttpAdapter`** implementiert `AiInvocationPort` für OpenAI-kompatible Endpunkte.
POST `{baseUrl}/v1/chat/completions`, Bearer-Authentifizierung, extrahiert
`choices[0].message.content`, klassifiziert HTTP-Fehler und Timeouts als
`AiInvocationTechnicalFailure`.
- **`...ai.AnthropicClaudeHttpAdapter`** implementiert `AiInvocationPort` für die native
Anthropic Messages API. POST `/v1/messages`, Header `x-api-key` und `anthropic-version`,
konkateniert `text`-Content-Blöcke aus dem Antwort-Array.
Beide Adapter liefern denselben Domain-Typ (`NamingProposal`) und enthalten keinerlei
provider-spezifische Typen in öffentlichen Signaturen. Welche Implementierung aktiv ist, entscheidet
ausschließlich der Bootstrap (→ `AiProviderSelector`).
#### Modell-Katalog (GUI)
- **`...modelcatalog.ClaudeModelCatalogAdapter`** `AiModelCatalogPort` für Claude,
GET `/v1/models` mit `x-api-key`.
- **`...modelcatalog.OpenAiCompatibleModelCatalogAdapter`** `AiModelCatalogPort` für
OpenAI-kompatibel, GET `/v1/models` mit Bearer.
#### PDF-Extraktion
- **`...pdfextraction.PdfTextExtractionPortAdapter`** PDFBox-3.x-Adapter. Alle technischen
Fehler werden als `PdfExtractionTechnicalError` zurückgegeben; es werden keine Exceptions
propagiert.
#### SQLite
- **`...sqlite.SqliteSchemaInitializationAdapter`** Flyway-basierte Schema-Initialisierung
mit `V1__initial_schema.sql`. Drei-Fall-Strategie: leere Datenbank (Flyway führt das Skript
vollständig aus), bestehender Datenbestand ohne Flyway-History (Schema-Prüfung, datiertes
Backup, dann Baseline-Eintrag ohne Skriptausführung), regulärer Folgestart mit Flyway-History
(idempotenter Lauf). Foreign-Key-Durchsetzung via `SQLiteConfig.enforceForeignKeys(true)` auf
DataSource-Ebene, sodass jede neue Verbindung automatisch `PRAGMA foreign_keys = ON` erhält.
- **`...sqlite.SqliteUnitOfWorkAdapter`** implementiert `UnitOfWorkPort`. Setzt
`autoCommit=false`, führt atomare Commits durch, rollt bei Fehlern zurück. Die innere
`TransactionOperations`-Implementierung wurde um `resetDocumentStatusForRetry(DocumentFingerprint)`
erweitert: setzt feldgenau `overall_status = 'READY_FOR_AI'`, `content_error_count = 0`,
`transient_error_count = 0`, `last_failure_instant = NULL`; alle anderen Felder und alle
`processing_attempt`-Einträge bleiben unangetastet.
- **`...sqlite.SqliteDocumentRecordRepositoryAdapter`** Stammsatz pro SHA-256-Fingerprint
(Gesamtstatus, Fehlerzähler, Zieldateiname usw.).
- **`...sqlite.SqliteProcessingAttemptRepositoryAdapter`** Versuchshistorie, referenziert
über Fingerprint. Enthält u. a. Provider-Identifikator, Modellname, Prompt-Identifikator,
KI-Rohantwort und finalen Zieldateinamen.
- **`...sqlite.SqliteHistoryQueryAdapter`** implementiert `HistoryQueryPort`. Kapselt alle
lesenden Datenbankoperationen für den Historien-Tab: Übersicht (`loadOverview` mit
Sortierung `updated_at DESC, fingerprint ASC`, LIMIT 501-Strategie, case-insensitive
Freitextsuche via `LOWER()` mit Sonderzeichen-Escape für `%` und `_`), Stammsatz-Lookup
(`findRecordByFingerprint`) und Versuchshistorie (`findAttemptsByFingerprint`).
#### Konfiguration
- **`...configuration.PropertiesConfigurationPortAdapter`** implementiert `ConfigurationPort`.
Lädt `config/application.properties` (oder einen `--config`-Override), parst via
`MultiProviderConfigurationParser`, löst API-Keys aus Umgebungsvariablen
(`OPENAI_COMPATIBLE_API_KEY`, `ANTHROPIC_API_KEY`).
- **`...configuration.LegacyConfigurationMigrator`** erkennt alte Flat-Key-Konfigurationen
(Schlüssel wie `api.baseUrl`, `api.model`), legt eine `.bak`-Sicherung an und überführt den
Inhalt in das aktuelle Multi-Provider-Schema.
#### Prompt-Adapter
- **`...prompt.FilesystemPromptPortAdapter`** implementiert `PromptPort`. Lädt das
Prompt-Template aus einer externen Datei und leitet den Identifikator aus dem Dateinamen ab.
Die neue Methode `savePrompt(String content)` schreibt den Inhalt atomar: temporäre Datei
im selben Verzeichnis anlegen (gleiche Partition), Inhalt in UTF-8 schreiben, dann
`ATOMIC_MOVE` zur Zieldatei. Kein stiller Fallback bei `AtomicMoveNotSupportedException`.
Der Pfad stammt aus der Adapter-internen Konfiguration, nicht aus dem Port-Aufruf.
#### Laufzeitinfrastruktur
- **`...lock.FilesystemRunLockPortAdapter`** Lock-Datei mit PID-Inhalt. Wirft
`RunLockUnavailableException`, wenn die Datei bereits vorhanden ist. Release löscht die Datei
(best-effort).
- **`...clock.SystemClockAdapter`** delegiert an `Instant.now()`.
- **`...fingerprint.Sha256FingerprintAdapter`** SHA-256 über den Rohdatei-Inhalt. Fehler als
`FingerprintTechnicalError`.
#### Zieldatei
- **`...targetcopy.FilesystemTargetFileCopyAdapter`** kopiert die Quelldatei zunächst in eine
`.tmp`-Datei, dann atomarer Move (Fallback: Standard-Move). Die Quelldatei wird in keinem Fall
verändert.
- **`...targetfolder.FilesystemTargetFolderAdapter`** ermittelt einen kollisionsfreien
Zieldateinamen mit `(1)`, `(2)`-Suffix. Erkennt inhaltsidentische Duplikate via SHA-256.
#### Validierung vor Prozessstart
- **`...bootstrap.validation.StartConfigurationValidator`** validiert die geladene
`StartConfiguration` auf Pflichtfelder, Wertebereiche, URI-Syntax und Pfadbedingungen.
Wird im Bootstrap-Headless-Pfad unmittelbar nach dem Laden der Konfiguration aufgerufen.
---
### Adapter-In-CLI
- **`...adapter.in.cli.SchedulerBatchCommand`** einziger Inbound-Adapter für den Headless-Betrieb.
Nimmt einen `BatchRunContext` entgegen, delegiert an `BatchRunProcessingUseCase.execute()` und
gibt `BatchRunOutcome` zurück. Enthält keine eigene Fachlogik; die Verdrahtung mit dem
Use-Case-Interface erfolgt ausschließlich im Bootstrap.
---
### Bootstrap
- **`...bootstrap.PdfUmbenennerApplication`** `main`-Methode. Parst CLI-Argumente via
`CliArgumentParser`, bricht bei ungültiger Verwendung mit Exit-Code 1 ab, delegiert an
`BootstrapRunner.run()` und ruft abschließend `System.exit()` mit dem zurückgegebenen Code auf.
- **`...bootstrap.BootstrapRunner`** Herzstück der Verdrahtung. Baut den Objektgraph für
Headless- und GUI-Pfad, dispatcht über `StartupMode`, enthält `buildProductionBatchUseCase()`
und `runHeadlessBatch()` als zentrale Kompositionsmethoden, liefert den Exit-Code zurück.
- **`...bootstrap.AiProviderSelector`** einzige Stelle, an der `AiProviderFamily` auf eine
konkrete `AiInvocationPort`-Implementierung abgebildet wird:
`OPENAI_COMPATIBLE``OpenAiHttpAdapter`, `CLAUDE``AnthropicClaudeHttpAdapter`.
- **`...bootstrap.startup.CliArgumentParser`** parst `--headless` und `--config <Pfad>` zu einem
typsicheren `StartupArgumentsParseResult` (sealed: `Valid` / `Invalid`).
- **`...bootstrap.singleinstance.SingleInstanceGuard`** bindet einen Loopback-ServerSocket auf
Port 47832. Wirft `AnotherInstanceRunningException`, wenn der Port bereits belegt ist. Ein
Shutdown-Hook gibt den Socket frei.
- **`...bootstrap.adapter.AiModelCatalogDispatcher`** Bootstrap-interner Dispatcher für die GUI.
Routet `AiModelCatalogPort`-Aufrufe anhand des `providerIdentifier` an den Claude- oder
OpenAI-kompatiblen Modell-Katalog-Adapter. Thread-safe.
- **`...bootstrap.ApplicationVersionProvider`** statische Hilfsklasse ohne Zustand. Liest
`Implementation-Version` aus dem Paket-Manifest via `getClass().getPackage().getImplementationVersion()`.
Fallback `"dev"` bei IDE-Start und ungepacktem Betrieb (kein Manifest-Eintrag vorhanden).
Der aufgelöste Wert wird im GUI-Pfad in `GuiStartupContext.applicationVersion` eingesetzt.
- **`...bootstrap.adapter.Log4jProcessingLogger`** implementiert `ProcessingLogger` auf Basis
von Log4j2. Unterdrückt sensitive KI-Inhalte, wenn `AiContentSensitivity.PROTECT_SENSITIVE_CONTENT`
gesetzt ist.
- **`...bootstrap.adapter.GuiConfigurationPropertiesWriter`** schreibt die im GUI-Editor
bearbeitete Konfiguration als normalisierte `application.properties` zurück auf das Dateisystem.
---
## 4. Verdrahtungslogik in Bootstrap
Die folgende Sequenz beschreibt den Ablauf von `main()` bis zum Start des eigentlichen Adapters.
Der Objektgraph wird ausschließlich durch manuelle `new`-Aufrufe aufgebaut; es wird kein
DI-Framework verwendet.
**Argument-Parsing**
- `PdfUmbenennerApplication.main()``CliArgumentParser.parse(args)`
- Ergebnis `Invalid` → Exit-Code 1, keine weiteren Schritte
**Einzelinstanz-Schutz**
- `BootstrapRunner.run()``SingleInstanceGuard.acquire()`
- `AnotherInstanceRunningException` → Exit-Code 1; im GUI-Modus zusätzlich ein Swing-Warndialog
**Modus-Dispatch**
- `BootstrapRunner.run()` wertet `startupArguments.mode()` aus:
- `HEADLESS``runHeadlessBatch()`
- `GUI``startGuiMode()`
**Konfigurationsauflösung (Headless-Pfad)**
- Prüfung, ob `--config`-Datei existiert (Fehler → Exit-Code 1)
- `LegacyConfigurationMigrator.migrateIfLegacy()` bei erkannter Legacy-Form
- `PropertiesConfigurationPortAdapter` lädt und parst die Properties
- `StartConfigurationValidator` validiert die geladene `StartConfiguration`
- Validierungsfehler → Exit-Code 1
**KI-Provider-Auswahl**
- Innerhalb von `buildProductionBatchUseCase()`:
`multiProviderConfiguration().activeProviderFamily()``AiProviderSelector.select(family, providerConfig)`
- Ergebnis: genau eine `AiInvocationPort`-Instanz
**Objektgraph-Aufbau (Headless)**
- Erzeugte Instanzen (Reihenfolge nach Abhängigkeit): `Sha256FingerprintAdapter`,
`SqliteDocumentRecordRepositoryAdapter`, `SqliteProcessingAttemptRepositoryAdapter`,
`SqliteUnitOfWorkAdapter`, `FilesystemTargetFolderAdapter`, `FilesystemTargetFileCopyAdapter`,
`FilesystemPromptPortAdapter`, `SystemClockAdapter`, `SourceDocumentCandidatesPortAdapter`,
`PdfTextExtractionPortAdapter`, `Log4jProcessingLogger`
- Application-Services (`DocumentProcessingCoordinator`, `AiResponseValidator`,
`AiNamingService`) werden verdrahtet und in `DefaultBatchRunProcessingUseCase` eingebettet
**CLI-Adapter**
- `BootstrapRunner` erzeugt `SchedulerBatchCommand` mit dem fertigen `BatchRunProcessingUseCase`
**Exit-Code-Ableitung**
- `BatchRunOutcome` → 0 (Lauf technisch erfolgreich) oder 1 (harter Bootstrap-/Konfigurationsfehler)
- `PdfUmbenennerApplication` ruft `System.exit(exitCode)` auf
**GUI-Pfad**
- `startGuiMode()` baut via `buildGuiStartupContext()` einen `GuiStartupContext`:
enthält `AiModelCatalogDispatcher`, `EnvironmentApiKeyResolutionAdapter`,
`TechnicalTestOrchestrator`, `GuiConfigurationPropertiesWriter`
- Bootstrap verdrahtet zusätzlich vier neue History-Use-Cases (`DefaultHistoryOverviewUseCase`,
`DefaultHistoryDetailsUseCase`, `DefaultHistoryResetDocumentStatusUseCase`,
`DefaultDeleteDocumentHistoryUseCase`) und den `DefaultPromptEditorUseCase` als anonyme
Bridge-Implementierungen in den `GuiStartupContext`
- `ApplicationVersionProvider.resolveVersion()` wird aufgerufen und der Wert in
`GuiStartupContext.applicationVersion` gesetzt
- Wenn eine Konfigurationsdatei beim Start bekannt ist, erzeugt Bootstrap zusätzlich einen
vollständig verdrahteten `GuiPromptEditorPort` (kombiniert `FilesystemPromptPortAdapter` mit
`DefaultPromptEditorUseCase`); ohne Konfiguration erhält der Context einen No-Op-Port
- `GuiAdapter.start(context)` übernimmt; ab diesem Punkt liegt die Kontrolle beim GUI-Adapter
- Im GUI-Pfad: keine SQLite-Schema-Initialisierung beim Start, kein Run-Lock-Erwerb, kein Batch-Use-Case;
History-Operationen initialisieren die Schema-Verbindung ad-hoc pro Aufruf
- GUI-interne Ports und deren Verbindung mit Outbound-Adaptern sind in
`docs/architecture/gui-overview.md` beschrieben
---
## 5. Einstiegspunkte je Modul
### pdf-umbenenner-adapter-out
1. **`...ai.OpenAiHttpAdapter`** zeigt das typische Adapter-Muster: Port-Interface implementieren,
alle provider-spezifischen Details kapseln, `ProviderConfiguration` als einzige
Konfigurationsquelle konsumieren. Danach `AnthropicClaudeHttpAdapter` zum Vergleich lesen.
2. **`...sqlite.SqliteSchemaInitializationAdapter`** erklärt das Datenbankschema, das alle
SQLite-Adapter voraussetzen. Hier sieht man, welche Felder in `document_record` und
`processing_attempt` existieren und wie Schema-Evolution additiv umgesetzt ist.
3. **`...configuration.PropertiesConfigurationPortAdapter`** Einstieg in die
Konfigurationskette. Von hier aus `MultiProviderConfigurationParser` und
`LegacyConfigurationMigrator` nachverfolgen.
### pdf-umbenenner-adapter-in-cli
1. **`...adapter.in.cli.SchedulerBatchCommand`** komprimiertes Inbound-Adapter-Muster in einer
einzigen Klasse. Zeigt, wie ein Inbound-Adapter ausschließlich über Port-Interfaces mit der
Application kommuniziert.
2. **`package-info.java`** beschreibt Abhängigkeitsrichtung und Verdrahtungsvertrag dieses
Adapters.
3. **`SchedulerBatchCommandTest`** zeigt, wie der Adapter ohne Bootstrap testbar ist.
### pdf-umbenenner-bootstrap
1. **`PdfUmbenennerApplication`** Startpunkt; die kurze Kette von `main()` bis `System.exit()`
gibt einen ersten Überblick über die gesamte Startsequenz.
2. **`BootstrapRunner`** Herzstück; `buildProductionBatchUseCase()` zeigt, wie der vollständige
Objektgraph manuell aufgebaut wird. `runHeadlessBatch()` zeigt den Headless-Kontrollfluss.
3. **`AiProviderSelector`** kleinste Klasse mit größter Hebelwirkung: hier liegt die einzige
Stelle, an der die Provider-Auswahl aus der Konfiguration auf eine konkrete
`AiInvocationPort`-Implementierung trifft.
---
*Port-Verträge und Domain-Typen: `docs/architecture/domain-overview.md`*
*GUI-interne Ports und GUI-Adapter-Struktur: `docs/architecture/gui-overview.md`*
+193
View File
@@ -0,0 +1,193 @@
# Architektur-Übersicht: Domain & Application
Dieses Dokument beschreibt die fachliche und anwendungsnahe Schicht des PDF-Umbenenners: das Modul `pdf-umbenenner-domain` und das Modul `pdf-umbenenner-application`. Es richtet sich an Entwickler, die in diesen beiden Modulen arbeiten, und soll als alleiniger Architekturkontext ausreichen ergänzt durch die `CLAUDE.md` im Projektroot. Nicht enthalten sind Adapter-Implementierungen (Dateisystem, PDFBox, SQLite, HTTP-Clients); diese sind in `adapter-overview.md` beschrieben. GUI-spezifische Ports und deren Einbettung in den Konfigurationseditor sind in `gui-overview.md` dokumentiert.
---
## 1. Modulzweck
### `pdf-umbenenner-domain`
Enthält ausschließlich fachliche Kerntypen (Records, Enums, Sealed Interfaces) ohne jegliche Infrastrukturabhängigkeiten. Alle Typen modellieren den Problembereich und sind von anderen Modulen referenzierbar, ohne transitive Abhängigkeiten einzuschleppen.
### `pdf-umbenenner-application`
Definiert Use-Case-Orchestrierung sowie alle Inbound- und Outbound-Ports der hexagonalen Architektur. Enthält anwendungsnahe Dienste (KI-Antwort-Parsing, Pre-Check-Auswertung, Retry-Entscheidung) und Konfigurationsmodelle, aber keinerlei Infrastrukturcode (kein JDBC, kein PDFBox, kein HTTP-Client, kein JavaFX).
---
## 2. Paketstruktur
### `pdf-umbenenner-domain`
| Paket | Verantwortung |
|-------|---------------|
| `de.gecheckt.pdf.umbenenner.domain` | Wurzelpaket; enthält nur `package-info.java` |
| `de.gecheckt.pdf.umbenenner.domain.model` | Alle fachlichen Kerntypen: Records, Sealed Interfaces und Enums, die die Verarbeitungsdomäne beschreiben |
### `pdf-umbenenner-application`
| Paket | Verantwortung |
|-------|---------------|
| `de.gecheckt.pdf.umbenenner.application` | Wurzelpaket des Application-Moduls |
| `de.gecheckt.pdf.umbenenner.application.port.in` | Inbound-Ports (Use-Case-Interfaces) Einstiegspunkte für den Aufrufer |
| `de.gecheckt.pdf.umbenenner.application.port.out` | Outbound-Ports Verträge gegenüber Infrastruktur-Adaptern (Persistenz, Dateisystem, KI, Uhr, Logging) |
| `de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog` | Spezialisierter Outbound-Port für den Abruf verfügbarer KI-Modelle; ausschließlich im GUI-Pfad genutzt (siehe `gui-overview.md`) |
| `de.gecheckt.pdf.umbenenner.application.port.out.history` | Outbound-Port für lesende Historien-Abfragen aus dem Historien-Tab; bewusst getrennt von den bestehenden Repositories, um diese nicht mit GUI-spezifischen Methoden aufzublähen |
| `de.gecheckt.pdf.umbenenner.application.service` | Anwendungsnahe, zustandslose Dienste: KI-Antwort-Parsing, Pre-Check-Auswertung, Verarbeitungs-Pipeline, Retry-Entscheidung |
| `de.gecheckt.pdf.umbenenner.application.config` | Konfigurationsmodelle der Anwendungsschicht (`RuntimeConfiguration`, Provider-Konfiguration) |
| `de.gecheckt.pdf.umbenenner.application.config.startup` | Vollständiges Startup-Konfigurationsmodell (`StartConfiguration`) |
| `de.gecheckt.pdf.umbenenner.application.config.provider` | Modelle für KI-Provider-Konfiguration (Provider-Familie, Einzelkonfiguration, Multi-Provider) |
| `de.gecheckt.pdf.umbenenner.application.validation.editor` | Validierungslogik für den GUI-Konfigurationseditor (Findings, Report, API-Key-Auflösung); siehe `gui-overview.md` |
| `de.gecheckt.pdf.umbenenner.application.validation.technicaltest` | Technischer Selbsttest: Pfad-Checks, Korrekturpläne, Checkpoints; Details in `gui-overview.md` |
| `de.gecheckt.pdf.umbenenner.application.usecase` | Paket-Marker für Use-Case-Implementierungen |
---
## 3. Schlüsselklassen
### Domain-Modul
**`de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate`**
Record für einen PDF-Kandidaten aus dem Quellordner. Enthält keinen `Path`, sondern einen opaken `SourceDocumentLocator`, damit die Domain frei von NIO-Typen bleibt.
**`de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint`**
Record mit einem SHA-256-Hex-String (64 Zeichen) als stabiler Dokumentidentität; Grundlage für Idempotenz und Persistenz-Lookup.
**`de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome`**
Sealed Interface mit sechs Implementierungen, die alle möglichen Ausgänge der Dokumentverarbeitung exhaustiv abbilden:
| Implementierung | Bedeutung |
|-----------------|-----------|
| `PreCheckPassed` | Vorprüfung bestanden, KI-Pfad freigegeben |
| `PreCheckFailed` | Deterministischer Inhaltsfehler vor KI-Aufruf |
| `TechnicalDocumentError` | Technischer Fehler ohne erneuten KI-Aufruf |
| `NamingProposalReady` | KI-Antwort gültig, Vorschlag liegt vor |
| `AiTechnicalFailure` | Transienter technischer Fehler beim KI-Aufruf |
| `AiFunctionalFailure` | Deterministischer fachlicher Fehler der KI-Antwort |
**`de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus`**
Enum mit acht Zuständen. Dokumentiert Zustandsübergänge und Retry-Schwellen; fachliches Herzstück der Persistenz-Semantik.
| Status | Bedeutung |
|--------|-----------|
| `READY_FOR_AI` | Verarbeitbar, KI-Pfad noch nicht durchlaufen |
| `FAILED_RETRYABLE` | Verarbeitbar, transient fehlgeschlagen |
| `PROPOSAL_READY` | Eingangszustand für Dateinamensbildung und Zielkopie |
| `SUCCESS` | Terminaler Enderfolg nur nach Zielkopie und konsistenter Persistenz |
| `FAILED_FINAL` | Terminal, wird nicht erneut fachlich verarbeitet |
| `SKIPPED_ALREADY_PROCESSED` | Historisierter Skip für `SUCCESS`-Dokumente |
| `SKIPPED_FINAL_FAILURE` | Historisierter Skip für `FAILED_FINAL`-Dokumente |
**`de.gecheckt.pdf.umbenenner.domain.model.NamingProposal`**
Record mit aufgelöstem Datum, `DateSource`, validiertem Titel und KI-Begründung. Führende Quelle für die Zieldateinamensbildung.
**`de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext`**
Klasse mit Run-ID, Zeitstempel und optionalem Fingerabdruck-Filter; steuert den Umfang eines Batch-Laufs.
---
### Application-Modul
**`de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration`**
Schmales Laufzeit-Record (`maxPages`, `maxRetriesTransient`, `aiContentSensitivity`). Wird von den Use Cases verwendet, enthält keine Pfade.
**`de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration`**
Vollständige typisierte Startup-Konfiguration; einziger Ort in der Anwendungsschicht, an dem `java.nio.file.Path` vorkommt. Wird vom `ConfigurationPort` geliefert und von Bootstrap ausgewertet.
**`de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingService`**
Statische Hilfsklasse: überführt ein Extraktionsergebnis über den Pre-Check in ein `DocumentProcessingOutcome`. Kompakte Pipeline-Klasse; guter Einstieg zum Verständnis der Verarbeitungslogik.
**`de.gecheckt.pdf.umbenenner.application.service.AiResponseParser`**
Statischer Parser für KI-Antworten in `ParsedAiResponse`. Erzwingt reines JSON-Objekt; Validierungslogik liegt vollständig in der Anwendungsschicht.
**`de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt`**
Record für einen Versuchshistorie-Eintrag; enthält u. a. Provider-Identifikator, Modellname, Prompt-Identifikator, aufgelöstes Datum und finalen Zieldateinamen.
**`de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord`**
Record für den Dokument-Stammsatz; enthält Gesamtstatus, Fehler- und Transientzähler sowie letzten Zielpfad.
**`de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery`**
Record mit den Abfrageparametern für den Historien-Tab: optionaler Suchbegriff (`searchText`, Teilstring, case-insensitiv), optionaler Status-Filter (`statusFilter` als Enum-Name) und Limit der zurückzugebenden Zeilen (Standard `DEFAULT_LIMIT = 501`). Das Limit 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr als 500 Treffer vorhanden sind.
**`de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow`**
Einzelzeile der Dokumentenliste im Historien-Tab. Felder: `fingerprint`, `overallStatus`, `sourceFileName`, `targetFileName` (null wenn noch kein Erfolg), `sourcePath`, `updatedAt` und `attemptCount`. Stammt aus `document_record` mit einem `COUNT`-Ausdruck über `processing_attempt`.
**`de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult`**
Versiegeltes Ergebnis-Interface für `PromptPort.savePrompt(String)`. Zulässige Ausprägungen: `Saved` (Erfolg, enthält absoluten Pfad), `WriteFailed` (I/O-Fehler beim Schreiben der Temp-Datei), `TargetDirectoryMissing` (Zielordner fehlt), `AtomicMoveFailed` (atomares Verschieben nicht möglich; kein stiller Fallback).
**Neue Use-Case-Implementierungen im Paket `de.gecheckt.pdf.umbenenner.application.usecase`**
| Klasse | Zweck |
|--------|-------|
| `DefaultHistoryOverviewUseCase` | Lädt die gefilterte Dokumentenübersicht über `HistoryQueryPort.loadOverview`; gibt `HistoryOverviewResult` mit Liste und `hasMore`-Flag zurück |
| `DefaultHistoryDetailsUseCase` | Lädt Stammsatz und alle Verarbeitungsversuche für einen Fingerprint über `HistoryQueryPort`; gibt `HistoryDetailsResult` zurück |
| `DefaultHistoryResetDocumentStatusUseCase` | Feldgenauer Status-Reset via `UnitOfWorkPort.TransactionOperations.resetDocumentStatusForRetry`; setzt `overall_status`, `content_error_count`, `transient_error_count` und `last_failure_instant` zurück; lässt die Versuchshistorie unangetastet |
| `DefaultDeleteDocumentHistoryUseCase` | Löscht Stammsatz und alle Verarbeitungsversuche vollständig und transaktional via `UnitOfWorkPort` |
| `DefaultPromptEditorUseCase` | Delegiert Laden, Speichern und Standard-Anlegen der Prompt-Datei an `PromptPort` und `ResourceCreationPort`; wird im GUI-Pfad über `GuiPromptEditorPort` angesteuert |
---
## 4. Inbound Ports
### `BatchRunProcessingUseCase`
```
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase
```
Zentraler Use-Case-Einstiegspunkt für den gesamten Batch-Betrieb. Beschreibt den Anwendungszweck in einer einzigen Methode:
```java
BatchRunOutcome execute(BatchRunContext context);
```
Mögliche Ergebnisse:
| Ergebnis | Bedeutung |
|----------|-----------|
| `SUCCESS` | Lauf technisch ordnungsgemäß abgeschlossen |
| `LOCK_UNAVAILABLE` | Run-Lock konnte nicht erworben werden |
| `FAILURE` | Harter technischer Fehler beim Laufstart |
---
## 5. Outbound Ports
Alle Outbound-Ports liegen in `de.gecheckt.pdf.umbenenner.application.port.out` (bzw. dessen Unterpaket `modelcatalog`). Implementierungen befinden sich ausschließlich in `pdf-umbenenner-adapter-out`; Details dort sind in `adapter-overview.md` beschrieben.
| Interface | Zweck | Hauptmethode(n) |
|-----------|-------|-----------------|
| `SourceDocumentCandidatesPort` | Scannt Quellordner, liefert Kandidaten in deterministischer Reihenfolge | `List<SourceDocumentCandidate> loadCandidates()` |
| `FingerprintPort` | Berechnet SHA-256-Fingerabdruck eines Kandidaten | `FingerprintResult computeFingerprint(SourceDocumentCandidate)` |
| `PdfTextExtractionPort` | Extrahiert Text und Seitenanzahl aus einer PDF | `PdfExtractionResult extractTextAndPageCount(...)` |
| `AiInvocationPort` | Ruft den aktiven KI-Dienst auf; provider-neutral | `AiInvocationResult invoke(AiRequestRepresentation)` |
| `PromptPort` | Lädt das Prompt-Template aus der konfigurierten Quelle; speichert geänderten Inhalt atomar via `savePrompt(String)` der Pfad stammt aus der Adapter-internen Konfiguration, nicht aus dem Port-Aufruf | `PromptLoadingResult loadPrompt()`, `PromptSaveResult savePrompt(String content)` |
| `TargetFileCopyPort` | Kopiert Quelldokument unter aufgelöstem Namen in den Zielordner (Temp + Rename) | `TargetFileCopyResult copyToTarget(...)` |
| `TargetFileRenamePort` | Atomare Umbenennung einer bereits kopierten Zieldatei (manuelle Korrektur) | `TargetFileRenameResult rename(...)` |
| `RunLockPort` | Exklusiver Lauf-Lock gegen parallele Instanzen | `acquire()` / `release()` |
| `PersistenceSchemaInitializationPort` | Idempotente Schema-Initialisierung der SQLite-Datenbank | `initializeSchema()` |
| `ClockPort` | Abstraktion des Systemtakts | `Instant now()` |
| `ConfigurationPort` | Lädt die typisierte Startup-Konfiguration | `StartConfiguration loadConfiguration()` |
| `ProcessingLogger` | Logging-Delegation; sensibles KI-Content-Logging über Flag gesteuert | `info/debug/warn/error/debugSensitiveAiContent(...)` |
| `AiModelCatalogPort` | Abruf verfügbarer Modelle vom Provider (nur GUI-Pfad, siehe `gui-overview.md`) | `ModelCatalogResult fetchAvailableModels(...)` |
| `PathCheckPort` | Lesende Pfad-Prüfung für den technischen Selbsttest | `isDirectoryReadable`, `isDirectoryWritableOrCreatable`, `isFileReadable`, `isSqlitePathUsable` |
| `ResourceCreationPort` | Schreibende Korrektur-Aktionen (Ordner anlegen, Prompt-Datei erzeugen, SQLite-Pfad vorbereiten) | `createDirectory`, `createPromptFile`, `prepareSqlitePath` |
| `ApiKeyResolutionPort` | Ermittelt API-Key-Herkunft pro Provider-Familie für die GUI-Validierung | `EffectiveApiKeyDescriptor resolve(...)` |
| `HistoryQueryPort` | Lesender Zugriff auf die Verarbeitungshistorie für den Historien-Tab; bewusst getrennt von den regulären Repositories | `List<DocumentHistoryRow> loadOverview(HistoryQuery)`, `Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint)`, `List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint)` |
> **Hinweis zu GUI-spezifischen Ports:** `AiModelCatalogPort`, `PathCheckPort`, `ResourceCreationPort`, `ApiKeyResolutionPort` und `HistoryQueryPort` werden ausschließlich im GUI-Pfad genutzt. Ihre Implementierungen und der Aufrufkontext sind in `gui-overview.md` bzw. `adapter-overview.md` beschrieben.
> **Hinweis zu `UnitOfWorkPort.TransactionOperations`:** Die innere Schnittstelle `TransactionOperations` wurde um die Methode `resetDocumentStatusForRetry(DocumentFingerprint)` erweitert. Diese setzt feldgenau `overall_status → READY_FOR_AI`, `content_error_count → 0`, `transient_error_count → 0` und `last_failure_instant → NULL`, ohne die Versuchshistorie zu berühren. Die Implementierung liegt in `SqliteUnitOfWorkAdapter`.
---
## 6. Einstiegspunkte für neue Entwickler
Die folgende Lesereihenfolge gibt den kürzesten Weg zum Gesamtverständnis:
1. **`de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase`** beschreibt den gesamten Anwendungszweck in einer Methode.
2. **`de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus`** fachliches Herzstück; dokumentiert Zustandsübergänge und Retry-Schwellen.
3. **`de.gecheckt.pdf.umbenenner.domain.model`** (gesamtes Paket) gemeinsame Sprache aller Schichten; vollständig in wenigen Minuten lesbar.
4. **`de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingService`** kompakte Pipeline-Klasse; zeigt, wie Pre-Check und Ergebnis-Typen zusammenspielen.
5. **`de.gecheckt.pdf.umbenenner.application.port.out`** (gesamtes Paket) vollständige Außengrenzen der Architektur; jeder Infrastrukturzugriff ist hier als Port definiert.
+209
View File
@@ -0,0 +1,209 @@
# Architektur-Übersicht: GUI (adapter-in-gui)
Diese Datei beschreibt den Inbound-Adapter `pdf-umbenenner-adapter-in-gui` die JavaFX-Desktop-GUI des PDF-Umbenenners. Sie ist zusammen mit `CLAUDE.md` im Projektroot als alleiniger Architekturkontext für GUI-Arbeit gedacht. Domain-Typen, Application-Ports und Outbound-Adapter (Dateisystem, SQLite, KI-HTTP) sind hier bewusst nicht beschrieben; dafür gelten `docs/architecture/domain-overview.md` und `docs/architecture/adapter-overview.md`. **Das JavaFX-Threading-Modell (Abschnitt 4) ist verbindlich und muss strikt eingehalten werden GUI-Entwickler sollten diesen Abschnitt als erstes lesen.**
---
## 1. Modulzweck
`pdf-umbenenner-adapter-in-gui` ist der Inbound-Adapter für die Desktop-Oberfläche. Er:
- empfängt den Startaufruf von der Bootstrap-Schicht über `GuiAdapter`,
- baut das JavaFX-Hauptfenster auf,
- delegiert alle fachlichen und technischen Operationen an Bootstrap-seitig verdrahtete Ports,
- zeigt Ergebnisse ausschließlich auf dem JavaFX Application Thread an.
Das Modul enthält **keine fachliche Logik**, keinen Datenbankzugriff, keinen HTTP-Code und keine PDF-Verarbeitung. Es koordiniert lediglich Benutzereingaben, Worker-Threads und JavaFX-Controls.
---
## 2. Paketstruktur
```
de.gecheckt.pdf.umbenenner.adapter.in.gui
├── (root) Einstiegspunkt, Hauptfenster, Orchestrierung, GUI-interne
│ Ports, Hilfsklassen für Fenstertitel, System-Tray,
│ Dateiladen/-schreiben und Startkontext.
│ Enthält außerdem: GuiStatusBar, GuiPromptEditorTab und
│ GuiPromptEditorPort.
├── batchrun Komponenten für den Tab „Verarbeitungslauf":
│ Worker-Koordinator, Tab-Ansicht, Ergebniszeilen,
│ PDF-Vorschau, Dateiname-Editor sowie GUI-interne
│ Port-Interfaces für Batch-Run, Mini-Run, manuelles
│ Umbenennen/Kopieren, Status-Reset und historischen Kontext.
│ Enthält außerdem: BatchRunSummaryBanner und
│ ProcessingStatusPresentation.
├── editor View-Modell- und Zustandstypen ohne JavaFX-Controls
│ (Ausnahme: GuiModelFieldContainer). Enthält Snapshot,
│ Baseline/Current-Values, Dirty-State-Berechnung,
│ Provider-Konfigurationszustände, API-Key-Zustände,
│ Validierungsergebnisse, Meldungs- und Feldbefund-Typen.
└── history Komponenten für den Tab „Verlauf": Tab-Ansicht mit
zweigeteiltem Layout (Liste + Detail), Filter, Aktionen
(Status-Reset / vollständiges Löschen) sowie die vier
Bridge-Interfaces GuiHistoryOverviewPort,
GuiHistoryDetailsPort, GuiHistoryResetDocumentStatusPort
und GuiDeleteDocumentHistoryPort.
```
**Tab-Reihenfolge:** `Konfiguration | Verarbeitungslauf | Verlauf | Prompt`
---
## 3. Schlüsselklassen
### Root-Paket
| Klasse (Kurzname) | Rolle |
|---|---|
| `GuiAdapter` | Einziger öffentlicher Bootstrap-Einstiegspunkt. Speichert `GuiStartupContext` im `GuiStartupContextHolder` und startet JavaFX via `Application.launch`. Genau einmal pro JVM aufrufbar. |
| `PdfUmbenennerGuiApplication` | JavaFX-`Application`-Unterklasse. Baut in `start(Stage)` Hauptfenster, `GuiConfigurationEditorWorkspace`, Titelaktualisierungs-Listener, Close-Handler und System-Tray auf. Triggert nach Anzeige `autoLoadLastConfiguration()`. |
| `GuiStartupContext` | Immutable Record mit allen Bootstrap-gelieferten Ports und Services: Dateilader/-schreiber, Modellkatalog-Port, API-Key-Resolution-Port, Technical-Test-Orchestrator, Correction-Execution-Service, Batch-Run-Launcher, Mini-Run-Launcher, Reset-Port, Manual-Rename-Port, Manual-Copy-Port, Historical-Context-Port, `applicationVersion` (Versions-String aus `ApplicationVersionProvider`), `promptEditorPort`, `historyOverviewPort`, `historyDetailsPort`, `historyResetDocumentStatusPort`, `deleteDocumentHistoryPort`. Bietet `blank()`-Fabrikmethode für Tests. |
| `GuiConfigurationEditorWorkspace` | Herzstück der Oberfläche. Baut `TabPane` mit den vier Tabs (Konfiguration, Verarbeitungslauf, Verlauf, Prompt), verwaltet `GuiConfigurationEditorState`, koordiniert Lade- und Schreibvorgänge auf Worker-Threads, steuert Dirty-State-Anzeige und Fenstertitel. |
| `GuiStatusBar` | Permanente Statuszeile am unteren Rand des Hauptfensters. Drei Segmente: links Anwendungsversion im Format `V<version>` (z. B. `Vdev`), Mitte aktiver Provider und Modell aus geladener Konfiguration, rechts Pfad der geladenen Konfigurationsdatei. Ohne geladene Konfiguration zeigen Mitte und Rechts den Platzhaltertext. |
| `GuiPromptEditorTab` | Tab „Prompt" mit `TextArea` zum Lesen, Bearbeiten und Speichern der Prompt-Datei. Dirty-State markiert den Tab-Titel mit einem Asterisk. Speichert atomar via `GuiPromptEditorPort`. Bietet „Auf Standard zurücksetzen" (füllt `TextArea` ohne zu speichern) und „Standard-Prompt erstellen" bei fehlender Datei. |
| `GuiModelCatalogCoordinator` | Asynchroner Modellabruf über `AiModelCatalogPort` auf Daemon-Thread `gui-model-catalog`. Liefert Ergebnis via `Platform.runLater` und aktualisiert ComboBox oder manuelles TextField. |
| `GuiTechnicalTestCoordinator` | Führt `TechnicalTestOrchestrator` asynchron auf Daemon-Thread `gui-technical-test` aus, ohne implizites Speichern. Replace-Semantik in `pendingMessages`. |
| `GuiCorrectionDialogCoordinator` | Empfängt `TechnicalTestReport`, leitet `CorrectionPlan` ab, zeigt gesammelten Bestätigungsdialog. Kein stiller Schreibzugriff. |
| `GuiUnsavedChangesGuard` | Drei-Wege-Schutzdialog (Speichern / Verwerfen / Abbrechen) vor Neu, Öffnen und Schließen. Dialog-Supplier ist injizierbar für Tests ohne Scene. |
| `SystemTrayManager` | Verwaltet Windows-System-Tray-Icon. Überbrückt AWT-EDT nach JavaFX via `Platform.runLater` für Stage-Operationen. |
### Paket `editor`
| Klasse (Kurzname) | Rolle |
|---|---|
| `GuiConfigurationEditorState` | Record mit `loadedFileSnapshot`, `baselineValues`, `values`, `pendingMigrationMessage`. Dirty-State wird per Vergleich berechnet, kein Flag. |
| `GuiConfigurationValues` | Hält alle editierbaren Konfigurationsfelder als JavaFX-freie Plain-Java-Typen. |
### Paket `batchrun`
| Klasse (Kurzname) | Rolle |
|---|---|
| `GuiBatchRunCoordinator` | Besitzt den Worker-Thread für Batch- und Mini-Run. Übersetzt `BatchRunProgressObserver`-Callbacks via `Platform.runLater`. Soft-Stop per `AtomicBoolean`. Enthält `toRow()` inkl. historischem Kontext-Nachladen. |
| `GuiBatchRunTab` | Zweiter Haupt-Tab mit `TableView`, `ProgressBar`, Start-/Stop-Buttons und Detailbereich. Implementiert `GuiBatchRunCoordinator.Listener`. |
| `BatchRunSummaryBanner` | Einzeilige Zusammenfassungsleiste nach Laufabschluss, unterhalb des Fortschrittsbalkens. Zeigt nur Kategorien mit Zähler > 0. Farbe ist niemals das einzige Unterscheidungsmerkmal: jedes Segment enthält Icon und Text. |
| `ProcessingStatusPresentation` | Zentrale Mapping-Klasse für Status-Icons, CSS-Farben, Tooltip-Texte und Summary-Kategoriebeschriften aller `DocumentCompletionStatus`-Werte. Einzige autoritative Quelle für alle Anzeigeorte (Tabelle, Detail, Banner). Enthält keine JavaFX-Typen; zustandslos und statisch. Farbe ist niemals das einzige Unterscheidungsmerkmal. |
| `PdfPreviewPane` | Asynchrones PDF-Rendering via PDFBox auf Single-Thread-Executor `pdf-preview-worker`. Stale-Request-Schutz via `AtomicLong`-Sequenznummer, In-Memory-Seiten-Cache. |
| `FileNameEditorPane` | Editor für den Zieldateinamen. Drei-Zustands-Modell: KI-Vorschlag / letzter gespeicherter / aktuelle Eingabe. Clientseitige Validierung; Speicher-Aufruf delegiert an Tab. |
| `AiFailureMessageTranslator` | Übersetzt englische technische Fehlermeldungen in deutsche Benutzertexte. Paket-privat, zustandslos. |
### Paket `history`
| Klasse (Kurzname) | Rolle |
|---|---|
| `GuiHistoryTab` | Tab „Verlauf" mit zweigeteiltem Layout: links Dokumentenliste mit Freitext- und Statusfilter, rechts Detailbereich mit Stammsatz und Versuchshistorie. Aktionen: Status-Reset (feldgenau, Versuchshistorie bleibt) und vollständiges Löschen (mit Bestätigungsdialog). Alle Datenbankoperationen auf Worker-Thread, UI-Updates via `Platform.runLater`. |
---
## 4. Threading-Modell
Das Modell ist verbindlich. Jede Verletzung dieser Regeln führt zu sporadischen `IllegalStateException`-Fehlern oder einer eingefrorenen Oberfläche.
### 4.1 Worker-Threads
Alle blockierenden Operationen laufen auf benannten Daemon-Threads außerhalb des JavaFX Application Thread.
| Thread-Name | Koordinator-Klasse | Operationen |
|---|---|---|
| `gui-batch-run` | `GuiBatchRunCoordinator` | Batch-Launcher, Mini-Run-Launcher, Reset-Port, historischer Kontext |
| `gui-model-catalog` | `GuiModelCatalogCoordinator` | `modelCatalogPort.fetchAvailableModels(...)` |
| `gui-technical-test` | `GuiTechnicalTestCoordinator` | `orchestrator.runTests(...)` |
| Korrektur-Worker (anonym) | `GuiCorrectionDialogCoordinator` | `correctionExecutionService.execute(...)` |
| `pdf-preview-worker` | `PdfPreviewPane` | `PDDocument` laden, `PDFRenderer.renderImageWithDPI`, `PDDocument.close` |
| Dateisystem-Worker (inline) | `GuiConfigurationEditorWorkspace` | `configurationFileLoader.load(...)`, `configurationFileWriter.write(...)` |
| Inline-Worker (anonym) | `GuiHistoryTab` | `historyOverviewPort.loadOverview(...)`, `historyDetailsPort.loadDetails(...)`, `historyResetDocumentStatusPort.resetStatus(...)`, `deleteDocumentHistoryPort.deleteHistory(...)` |
| Inline-Worker (anonym) | `GuiPromptEditorTab` | `promptEditorPort.loadCurrentPrompt()`, `promptEditorPort.save(...)`, `promptEditorPort.createDefaultPromptIfMissing(...)` |
### 4.2 JavaFX Application Thread
Alle Mutationen an JavaFX-Controls und alle Dialoganzeigen ausschließlich auf dem JavaFX Application Thread. Kein direktes Schreiben auf Controls vom Worker-Thread.
### 4.3 Übergangsmechanismus Worker → FX
Der Übergang erfolgt grundsätzlich via:
```java
Platform.runLater(runnable);
```
Es werden **keine** `javafx.concurrent.Task` und kein `Service` verwendet. Die Koordinatoren steuern Threading manuell über zwei injizierbare Strategien:
| Injektionspunkt | Typ | Produktion | Tests |
|---|---|---|---|
| `threadFactory` | `Function<Runnable, Thread>` | `Thread::new` (Daemon) | synchroner Direktaufruf |
| `fxDispatcher` | `Consumer<Runnable>` | `Platform::runLater` | synchroner Direktaufruf |
Durch diese Injektion sind Unit-Tests vollständig ohne JavaFX-Runtime möglich.
### 4.4 Stale-Request-Schutz
`PdfPreviewPane` vergibt für jede Renderanfrage eine inkrementelle `AtomicLong`-Sequenznummer. Ein abgeschlossenes Render-Ergebnis wird nur dann auf der UI angezeigt, wenn seine Sequenznummer noch der aktuellen entspricht. Veraltete Ergebnisse werden still verworfen.
---
## 5. GUI-interne Ports
> **Abgrenzung:** Die folgenden Interfaces sind **keine hexagonalen Outbound-Ports der Application-Schicht**. Sie sind modul-interne Brücken, über die `GuiAdapter` die Bootstrap-seitig verdrahteten Implementierungen in die GUI-Klassen einschleust. Die eigentlichen Application-Ports (`AiInvocationPort`, `AiModelCatalogPort` usw.) und deren Outbound-Adapter-Implementierungen sind in `docs/architecture/domain-overview.md` und `docs/architecture/adapter-overview.md` beschrieben.
### Root-Paket
| Interface | Zweck |
|---|---|
| `GuiConfigurationFileLoader` | Lädt eine `.properties`-Datei und liefert einen `GuiConfigurationEditorState`. Abstrahiert Migration und Bootstrap-Verdrahtung vom GUI-Code. |
| `GuiConfigurationFileWriter` | Schreibt aktuelle `GuiConfigurationValues` als normalisierte `.properties` inkl. Backup-Schema. |
### Paket `batchrun`
| Interface | Zweck |
|---|---|
| `GuiBatchRunLauncher` | Bootstrap-Brücke für den regulären Batch-Run auf dem Worker-Thread. |
| `GuiMiniRunLauncher` | Bootstrap-Brücke für einen auf einen Fingerprint-Filter beschränkten Mini-Run. |
| `GuiResetDocumentStatusPort` | Bootstrap-Brücke für den vollständigen Persistenz-Reset (Stammsatz und Versuchshistorie werden gelöscht) ohne Folge-Run. |
| `GuiManualFileRenamePort` | Bootstrap-Brücke für die manuelle Umbenennung der Zieldatei (Worker-Thread). |
| `GuiManualFileCopyPort` | Bootstrap-Brücke für die Kopie mit benutzerdefiniertem Zieldateinamen bei FAILED/SKIPPED-Dokumenten (Worker-Thread). |
| `GuiHistoricalDocumentContextPort` | Nachladen des vollständigen historischen Verarbeitungskontexts für übersprungene Dokumente (Worker-Thread). |
| `GuiHistoricalFileNamePort` | Spezialisierter Port für den letzten bekannten KI-Dateinamen. Weitgehend durch `GuiHistoricalDocumentContextPort` abgelöst, aber noch im Einsatz. |
### Root-Paket (GUI-interne Ports)
| Interface | Zweck |
|---|---|
| `GuiPromptEditorPort` | Bootstrap-Brücke zum Prompt-Editor-Use-Case: Laden (`loadCurrentPrompt()`), atomares Speichern (`save(String)`) und Standard-Anlegen (`createDefaultPromptIfMissing(...)`) der Prompt-Datei. |
### Paket `history`
| Interface | Zweck |
|---|---|
| `GuiHistoryOverviewPort` | Bootstrap-Brücke zur Historien-Übersicht; lädt gefilterte Dokumentenliste via `loadOverview(Path configFilePath, HistoryQuery)`. `configFilePath` ermöglicht der Bootstrap-Implementierung, die SQLite-Datenbank aus der aktuell geladenen Konfiguration abzuleiten. |
| `GuiHistoryDetailsPort` | Bootstrap-Brücke zur Detailansicht; lädt Stammsatz und alle Verarbeitungsversuche für einen Fingerprint via `loadDetails(Path, DocumentFingerprint)`. |
| `GuiHistoryResetDocumentStatusPort` | Bootstrap-Brücke für den feldgenauen Status-Reset im Historien-Tab (`overall_status → READY_FOR_AI`, Fehlerzähler → 0, `last_failure_instant → null`). Die Versuchshistorie bleibt vollständig erhalten. **Abgrenzung:** `GuiResetDocumentStatusPort` im `batchrun`-Paket löscht dagegen Stammsatz und Versuchshistorie vollständig. |
| `GuiDeleteDocumentHistoryPort` | Bootstrap-Brücke zum vollständigen Löschen von Stammsatz und Versuchshistorie via `deleteHistory(Path, DocumentFingerprint)`; destruktiv und nicht rückgängig zu machen. Die GUI zeigt vor dem Aufruf einen Bestätigungsdialog. |
Alle Implementierungen dieser Interfaces liegen in `pdf-umbenenner-bootstrap`. Das GUI-Modul kennt ausschließlich die Interface-Typen.
---
## 6. Einstiegspunkte für neue Entwickler
Folgende Klassen und Dateien decken den schnellsten Einstieg ab:
1. **`GuiAdapter`** Architekturgrenze zur Bootstrap-Schicht in zwei Methoden. Zeigt, wie die GUI aus Bootstrap-Sicht aufgerufen wird.
2. **`GuiStartupContext`** Vollständige Liste aller Ports und Services, die Bootstrap in die GUI injiziert. Wer wissen will, was die GUI von außen bekommt, liest diesen Record.
3. **`GuiConfigurationEditorWorkspace`** Zentrale UI-Klasse: Tab-Aufbau, Sektionen, Editor-Zustand, Dirty-State, Datei-I/O, Sub-Koordinatoren. Einstieg für alle Arbeiten am Konfigurationseditor-Tab.
4. **`GuiConfigurationEditorState` / `GuiConfigurationValues`** View-Modell ohne JavaFX-Controls. Einstieg für alle Änderungen an editierbaren Konfigurationsfeldern und Dirty-State-Logik.
5. **`GuiBatchRunCoordinator`** Threading-Modell in seiner reinsten Form: Worker-Thread, `Platform.runLater`-Übergabe, Soft-Stop, Listener-Protokoll. Einstieg für alle Arbeiten am Verarbeitungslauf-Tab.
6. **`batchrun/package-info.java`** Kompakte Beschreibung des Threading-Kontrakts, der Abbruch-Semantik und der Konfigurationsquelle für dieses Paket.
### Querverweise
- Application-Ports und Domain-Typen (`NamingProposal`, `ProcessingStatus`, `DocumentFingerprint` usw.): `docs/architecture/domain-overview.md`
- Outbound-Adapter-Implementierungen (Dateisystem, SQLite, KI-HTTP, PDFBox) und Bootstrap-Verdrahtung: `docs/architecture/adapter-overview.md`
+41 -3
View File
@@ -236,7 +236,7 @@ enthält JavaFX (Win-Classifier), alle Module, PDFBox, SQLite-JDBC und Log4j2.
| 15 | Legacy-Migration mit `.bak`-Sicherung | **erfüllt** | `LegacyConfigurationMigrator` in `adapter-out`; GUI-Pfad ruft `detectedLegacyConfiguration` + `migrateConfigurationIfNeeded` in `BootstrapRunner` auf. `GuiConfigurationPropertiesWriterTest` prüft Backup-Schema. |
| 16 | Keine neuen Provider über Claude/OpenAI-kompatibel hinaus | **erfüllt** | Codebase enthält ausschließlich `ClaudeAiInvocationAdapter` und `OpenAiCompatibleAiInvocationAdapter`. Kein dritter Provider. |
| 17 | Keine neuen Distributionsformate (EXE/Installer) | **erfüllt** | `pom.xml` des Bootstrap-Moduls nutzt ausschließlich `maven-shade-plugin`. Kein `launch4j`, kein `jpackage`, kein Installer. |
| 18 | Kein manueller Verarbeitungslauf aus GUI | **erfüllt** | `adapter-in-gui` enthält keine Klasse, die `BatchRunProcessingUseCase` aus einem GUI-Event aufruft. Kein „Start"-Button, keine Batch-Ausführungslogik im GUI-Adapter. |
| 18 | Kein manueller Verarbeitungslauf aus GUI (abgelöst ab V2.1) | **erfüllt** | `adapter-in-gui` enthält keine Klasse, die `BatchRunProcessingUseCase` aus einem GUI-Event aufruft. Kein „Start"-Button, keine Batch-Ausführungslogik im GUI-Adapter. |
| 19 | Keine DB-/Historienanzeige | **erfüllt** | Kein SQLite-Lesepfad aus `adapter-in-gui`. Kein Historien-Tab. Kein Ergebnis-Browser. |
| 20 | Keine fachlichen Änderungen an Kernverarbeitung | **erfüllt** | `DefaultBatchRunProcessingUseCase`, `DocumentProcessingCoordinator`, `AiNamingService`, `AiResponseValidator` sind gegenüber dem V1.1-Freigabestand unverändert. E2E-Tests (`BatchRunEndToEndTest`, 11 Szenarien) sind alle grün. |
@@ -293,10 +293,10 @@ GUI-Teststrategie (kein TestFX über Monocle hinaus) und ist keine Abweichung vo
Die folgenden Themen wurden im V2.0-Umfang nachweislich **nicht** implementiert und sind
ausdrücklich für spätere Ausbaustufen vorgesehen:
- **Manueller Verarbeitungslauf aus der GUI** (V2.1+)
- **Manueller Verarbeitungslauf aus der GUI** (V2.1+) **umgesetzt ab V2.1** (Tab „Verarbeitungslauf")
- **DB-/Historienansicht** in der GUI (V2.x+)
- **Kosten-Tracking** und Token-/Preisberechnung (V2.x+)
- **EXE-Wrapper / Installer** (V3+)
- **EXE-Wrapper / Installer** (V3+) **umgesetzt ab V3**: EXE-Wrapper (M14), MSI-Installer (M15)
- **Weitere KI-Provider** über Claude und OpenAI-kompatibel hinaus (V3+)
- **Automatischer Fallback zwischen Providern** (V3+)
- **Profilverwaltung mit mehreren Konfigurationen je Provider** (V3+)
@@ -315,3 +315,41 @@ ausdrücklich für spätere Ausbaustufen vorgesehen:
**Build:** ERFOLGREICH · 1.398 Tests · 0 Failures · 0 Errors · Laufzeit 01:18 min
**Alle 20 Spezifikations-Prüfpunkte:** erfüllt
**Dokumentation:** vollständig und konsistent
---
# V2.9-Fixes (Stand 2026-04-24)
Die folgenden Issues wurden nach dem V2.0-Abschluss behoben und sind im aktuellen Stand integriert.
| Issue | Titel | Status |
|---|---|---|
| #27 | Mausrad-Seitenwechsel und zuverlässiger Seitenanfang in PDF-Vorschau | **behoben** |
| #28 | Anwendung standardmäßig im Vollbild starten | **behoben** |
| #29 | Eigenes PDF-Rendering mit PDFBox statt PDFViewFX | **behoben** |
| #33 | Letzte Konfigurationsdatei beim Neustart automatisch laden | **behoben** |
### Beschreibung der Fixes
**#27 / #29 PDF-Vorschau-Stabilität und PDFBox-Migration:**
Mehrere aufeinanderfolgende Fixes stabilisierten die PDF-Vorschau. Zunächst wurden
Scroll-Schutz und zuverlässiger Seitenanfang per ImageView-Listener verbessert.
Im letzten Schritt (#29) wurde die externe PDFViewFX-Abhängigkeit vollständig
durch direktes Rendering via `PDFRenderer.renderImageWithDPI` (Apache PDFBox, 120 DPI)
ersetzt. Lazy Rendering mit In-Memory-Cache und das „latest preview request wins"-Prinzip
blieben erhalten.
**#28 Vollbild-Start:**
`stage.setMaximized(true)` in `PdfUmbenennerGuiApplication.start()` sorgt dafür, dass
das Fenster beim Start automatisch maximiert wird.
**#33 Letzte Konfiguration automatisch laden:**
`GuiConfigurationEditorWorkspace` speichert den Pfad einer erfolgreich geladenen
Konfigurationsdatei in `java.util.prefs.Preferences` (Schlüssel `lastConfigPath`).
Beim nächsten Start wird diese Datei automatisch geladen, sofern sie noch existiert.
Fehlt die Datei, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
---
> **Hinweis:** Für die Ausbaustufen V2.1 bis V2.9 wurden keine separaten Befundlisteneinträge
> erstellt. Befunde, Fixes und Verbesserungen dieser Stufen sind in den Gitea-Issues dokumentiert.
+301 -13
View File
@@ -54,17 +54,78 @@ Windows Server-Betrieb geeignet.
Gemappte Netzlaufwerke wie `S:\` oder `H:\` werden ausdrücklich unterstützt. Eine Ablehnung
solcher Pfade allein wegen eines dahinterliegenden UNC-Pfads ist unzulässig.
### Umfang der V2.0-GUI
### Startverhalten der GUI
Die GUI in V2.0 dient ausschließlich als:
Die GUI startet **maximiert** (Vollbild). Beim Start wird die zuletzt geladene
Konfigurationsdatei automatisch geladen. Der Pfad wird in den Windows-Benutzereinstellungen
gespeichert (`java.util.prefs.Preferences`). Existiert die Datei beim nächsten Start nicht
mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
- **Konfigurationseditor** für die `.properties`-Datei
- **Validierungsoberfläche** (automatische und explizite Prüfung des Konfigurationsstands)
- **Technische Testoberfläche** (Erreichbarkeit des Providers, Pfade, SQLite-Datei, Prompt-Datei)
### Umfang der GUI
Die GUI enthält in V2.0 **keinen** manuellen Verarbeitungslauf. Das Starten eines Batch-Laufs
aus der GUI ist erst ab V2.1+ vorgesehen. Der headless Betrieb über den Windows Task Scheduler
bleibt der einzige Weg, PDF-Dateien automatisiert zu verarbeiten.
Die GUI enthält fünf Tabs:
- **Tab „Konfiguration"** Editor, Validierungs- und technische Testoberfläche für
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
Prompt-Datei).
- **Tab „Verarbeitungslauf"** Start eines Batch-Laufs aus der GUI mit
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument. Pro Zeile ist eine
**integrierte PDF-Vorschau** der Quelldatei sowie ein **editierbarer Dateiname-Bereich**
verfügbar. Der Lauf verwendet den zuletzt gespeicherten Stand der `.properties`-Datei;
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
- **Tab „Scheduler"** Optionaler automatischer Scheduler für periodische Verarbeitungsläufe.
Kann gestartet, gestoppt und mit einem konfigurierten Intervall betrieben werden. Während
der Scheduler aktiv ist, sind Tab 1 „Konfiguration" und der manuelle Lauf gesperrt.
Erfordert `scheduler.enabled=true` und ein gültiges `scheduler.interval.seconds` in der
gespeicherten Konfiguration.
- **Tab „Verlauf"** Ansicht aller bisher verarbeiteten Dokumente mit Status, Dateinamen
und Verarbeitungsdetails direkt aus der SQLite-Datenbank. Ermöglicht Status-Reset und
Löschung einzelner Einträge.
- **Tab „Prompt"** Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt
aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel).
Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`).
Ein „Auf Standard zurücksetzen"-Button befüllt die TextArea mit der Standard-Vorlage,
ohne zu speichern. Fehlt die Prompt-Datei am konfigurierten Pfad, wird ein
„Standard-Prompt erstellen"-Button angezeigt. Der Tab wird beim ersten Öffnen automatisch
geladen. Tab-Wechsel mit ungespeicherten Änderungen löst einen Bestätigungsdialog aus.
Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und
kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau
ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf
wird jedoch nicht technisch erkannt oder blockiert.
### Automatischer Scheduler
Der GUI-Tab „Scheduler" ermöglicht den Betrieb eines optionalen, periodisch laufenden
Schedulers, der automatisch Verarbeitungsläufe anstößt.
**Konfigurationsparameter:**
| Parameter | Beschreibung | Standard |
|---|---|---|
| `scheduler.enabled` | Scheduler im GUI-Modus aktivieren (`true`/`false`); wird im headless Betrieb ignoriert | `false` |
| `scheduler.interval.seconds` | Intervall zwischen automatischen Läufen in Sekunden (Integer >= 30; Pflicht wenn `scheduler.enabled=true`); wird im headless Betrieb ignoriert | |
Ungültige Werte (kein Integer, < 30 oder leer bei `scheduler.enabled=true`) verhindern den
Scheduler-Start und werden im GUI-Tab als Fehler gemeldet.
**Autostart:** Ist `scheduler.enabled=true` in der gespeicherten Konfiguration, startet der
Scheduler automatisch, wenn die Konfiguration beim GUI-Start geladen wird. Der erste
Verarbeitungslauf beginnt **unmittelbar** nach dem Scheduler-Start (kein initiales Warten).
**Headless-Betrieb:** Im headless Betrieb werden `scheduler.enabled` und
`scheduler.interval.seconds` vollständig ignoriert. Der Scheduler ist ausschließlich im
GUI-Modus verfügbar.
**Sperrverhalten:** Solange der Scheduler aktiv ist, ist Tab 1 „Konfiguration" gesperrt
(Bearbeitungssperre mit Hinweisbanner). Manuelles Starten eines Laufs ist ebenfalls nicht
möglich. Nach dem Stoppen des Schedulers werden beide Sperren automatisch aufgehoben.
**Schließen der Anwendung:** Versucht der Benutzer das Fenster zu schließen, während der
Scheduler aktiv ist oder ein Lauf läuft, erscheint ein Informationsdialog. Das Schließen
wird blockiert, bis der Scheduler gestoppt und kein Lauf mehr aktiv ist.
---
@@ -102,6 +163,28 @@ java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHO
Die Anwendung liest die Konfiguration standardmäßig aus `config/application.properties` relativ zum
Arbeitsverzeichnis, in dem der Befehl ausgeführt wird.
### Konsolen-Encoding unter Windows
Die Anwendung schreibt alle Log-Ausgaben in UTF-8. Windows-Konsolen (PowerShell, CMD) verwenden
standardmäßig den OEM-Codepage (z. B. CP850), was zu unlesbaren Sonderzeichen führt.
**Lösung:** Konsole vor dem Start auf UTF-8 umschalten:
```
chcp 65001
java -jar pdf-umbenenner-bootstrap-*.jar --headless
```
Alternativ kann die UTF-8-Ausgabe auch als JVM-Argument angegeben werden (Java 17+):
```
java -Dstdout.encoding=UTF-8 -jar pdf-umbenenner-bootstrap-*.jar --headless
```
> **Hinweis:** Die mitgelieferten Batch-Dateien (`PDF-KI-Renamer.bat`, `PDF-KI-Renamer-GUI.bat`)
> rufen `chcp 65001` automatisch auf. Der Windows Task Scheduler schreibt Log-Ausgaben in eine
> Protokolldatei, die stets UTF-8-kodiert ist dort entsteht kein Anzeigeproblem.
### Start über Windows Task Scheduler
Empfohlene Startsequenz für den headless Betrieb über den Windows Task Scheduler:
@@ -152,6 +235,7 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in:
| `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) |
| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) |
| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) |
| `max.title.length` | Maximale Länge des Basistitels in Zeichen (ganzzahlig, 10..120, Default 60). Werte unter 10 oder über 120 verhindern den Start. Werte 1039 und 100120 erzeugen eine Startwarnung. |
| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) |
### Provider-Parameter
@@ -184,6 +268,8 @@ Nur der **aktive** Provider muss vollständig konfiguriert sein. Der inaktive Pr
| `log.directory` | Log-Verzeichnis | `./logs/` |
| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` |
| `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` |
| `scheduler.enabled` | Scheduler im GUI-Modus aktivieren (`true`/`false`); wird im headless Betrieb ignoriert | `false` |
| `scheduler.interval.seconds` | Intervall in Sekunden (Integer >= 30; Pflicht wenn `scheduler.enabled=true`); wird im headless Betrieb ignoriert | |
### API-Schlüssel
@@ -254,6 +340,35 @@ Die Anwendung ergänzt den Prompt automatisch um:
- einen Dokumenttext-Abschnitt
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
### Prompt-Pfad-Auflösung je Betriebsart
Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst,
wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab:
| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert |
|---|---|---|
| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` |
| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` |
| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` |
| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen |
> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad**
> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis
> immer eindeutig auffindbar insbesondere beim Start über den Windows Task Scheduler,
> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann.
### Bearbeitung über den GUI-Prompt-Tab
Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und
gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn
das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem
Fall wird kein stiller Fallback durchgeführt).
Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert
war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"),
aktualisiert sich der Prompt-Tab nicht automatisch in diesem Fall sollte die GUI neu gestartet
oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden.
---
## Zielformat
@@ -271,7 +386,7 @@ YYYY-MM-DD - Titel(1).pdf
YYYY-MM-DD - Titel(2).pdf
```
Das Suffix zählt nicht zu den 20 Zeichen des Basistitels.
Das Suffix zählt nicht zur konfigurierten maximalen Titellänge des Basistitels.
---
@@ -351,7 +466,27 @@ Die Anwendung verwendet eine exklusive Lock-Datei, um parallele Instanzen zu ver
Wenn bereits eine Instanz läuft, beendet sich die neue Instanz sofort mit Exit-Code `1`.
Der Pfad der Lock-Datei ist über `runtime.lock.file` konfigurierbar.
Ohne Konfiguration wird `pdf-umbenenner.lock` im Arbeitsverzeichnis verwendet.
### Pfadauflösung der Lock-Datei
| Pfadtyp | Verhalten |
|---|---|
| **Absoluter Pfad** | Wird direkt verwendet. Schlägt das Anlegen der Lock-Datei fehl, bricht der Start mit einer klaren Fehlermeldung ab kein Fallback. |
| **Relativer oder unkonfigurierter Pfad** | Zweistufige Auflösung: (1) relativ zum Verzeichnis der JAR-Datei (`CodeSource.getLocation()`), (2) Fallback auf das Benutzerverzeichnis (`user.home`). Erst wenn auch `user.home` fehlschlägt, bricht der Start ab. |
Fehlende übergeordnete Verzeichnisse werden automatisch angelegt.
Der tatsächlich verwendete absolute Pfad der Lock-Datei wird beim Start auf INFO-Level geloggt, z. B.:
```
Lock-Datei: C:\Users\Funny\Documents\pdf-umbenenner.lock
```
Diese Auflösungslogik gilt sowohl für den GUI- als auch für den headless Start.
> **Empfehlung für den MSI-Betrieb:** Da das Installationsverzeichnis `C:\Program Files\`
> schreibgeschützt ist, muss `runtime.lock.file` als absoluter Pfad auf ein beschreibbares
> Verzeichnis zeigen (z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock`).
---
@@ -361,11 +496,50 @@ Die SQLite-Datei enthält:
- **Dokument-Stammsätze**: Gesamtstatus, Fehlerzähler, letzter Zieldateiname, Zeitstempel
- **Versuchshistorie**: Jeder Verarbeitungsversuch mit Modell, Prompt-Identifikator,
KI-Rohantwort, Reasoning, Datum, Titel und Fehlerstatus
KI-Rohantwort, Reasoning, Datum, Titel, Fehlerstatus und Fehlerdetails
Die Datenbank ist die führende Wahrheitsquelle für Bearbeitungsstatus und Nachvollziehbarkeit.
Sie muss nicht manuell verwaltet werden das Schema wird beim Start automatisch initialisiert.
### Fehlerursache im Verlauf-Tab
Verarbeitungsversuche mit Status `FAILED_FINAL`, `FAILED_RETRYABLE` oder
`SKIPPED_FINAL_FAILURE` speichern eine nutzerverständliche Fehlerursache
(`failure_details`). Diese wird im Verlauf-Tab im Detailbereich des jeweiligen
Dokuments angezeigt. Ältere Einträge ohne Fehlerdetails zeigen einen Platzhaltertext.
Fehlerdetails werden auf 1000 Zeichen begrenzt und enthalten keine rohen
Provider-Meldungen oder API-Schlüssel.
### Neue Datenbank anlegen
Über den Menüpunkt **Datenbank → Neue Datenbank anlegen...** kann aus der GUI
heraus eine neue, leere SQLite-Datenbank erstellt und sofort aktiviert werden,
ohne die Anwendung neu zu starten.
**Ablauf:**
1. Dateidialog öffnet (Filter: `*.sqlite` und `*.db`); Zieldatei wählen oder eingeben.
2. Sicherheitsprüfung: aktive und gewählte Datei werden normalisiert verglichen
(case-insensitive unter Windows). Bei Übereinstimmung erscheint eine Fehlermeldung.
3. Bei bereits existierender Fremddatei: Bestätigungsdialog „Die Datei existiert bereits.
Überschreiben?"
4. Neue SQLite-Datei wird als temporäre Datei erzeugt, Flyway führt alle Migrationsskripte
auf neuesten Stand aus, dann Verbindungstest.
5. Nach erfolgreichem Test: atomarer Move zur Zieldatei.
6. Aktive Datenbankverbindung der Anwendung wechselt zur neuen DB.
7. Der Verlauf-Tab lädt neu und zeigt „Noch keine Verarbeitungen vorhanden."
8. Die Statuszeile aktualisiert den DB-Pfad.
> **Wichtig:** Die Konfigurationsdatei wird durch den Wechsel automatisch als geändert
> markiert. **Konfiguration speichern**, damit die neue Datenbank beim nächsten Start
> der Anwendung verwendet wird.
**Fehlerfall:** Schlägt ein Schritt fehl, bleibt die bisherige Datenbank unverändert
in Betrieb. Die temporäre Datei wird gelöscht. Ein Fehlerdialog erscheint.
Der Menüpunkt ist nur aktiv, wenn kein Verarbeitungslauf läuft.
Der headless Betrieb ist von dieser Funktion nicht betroffen.
---
## Build und Packaging
@@ -413,6 +587,10 @@ benötigt keine separate Java-Installation auf dem Zielsystem. Das Shade-JAR ble
primäre Distributionsartefakt; der MSI ist eine zusätzliche Option für Systeme ohne
Java-Installation und für den Standard-Installationspfad nach `C:\Program Files\`.
> **Hinweis zur CI-Umgebung:** Der MSI-Build ist Windows-only (`jpackage` + WiX Toolset 3.x).
> Jenkins läuft im Linux-Container auf dem Synology NAS und kann kein MSI erzeugen.
> Der MSI-Build wird bewusst manuell auf der Windows-Entwicklungsmaschine ausgeführt.
**Voraussetzungen für den Installer-Build (nur auf der Entwicklungsmaschine):**
- Windows x64
- JDK 21 im PATH
@@ -472,6 +650,74 @@ Installationsverzeichnis ab. **Der Betreiber muss diese Beispieldatei manuell na
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
**Empfehlung für Pfade im MSI-Betrieb:**
Für den MSI-Betrieb (Startmenü, Task Scheduler) müssen alle Dateipfade als **absolute Pfade**
konfiguriert werden. Relative Pfade werden relativ zum Installationsverzeichnis
`C:\Program Files\PDF KI Renamer\` aufgelöst, das **schreibgeschützt** ist. Dadurch
schlagen Schreibversuche (Logs, SQLite-Datenbank, Lock-Datei) ohne Fehlermeldung fehl.
> **Warnung Relative Pfade im MSI-Betrieb nicht verwenden:**
> Pfade wie `./logs`, `./work/local/logs` oder `logs/` werden im MSI-Betrieb relativ
> zum Installationsverzeichnis aufgelöst. Das Installationsverzeichnis ist für normale
> Benutzerkonten schreibgeschützt. Log4j2 scheitert dann still, ohne eine sichtbare
> Fehlermeldung zu erzeugen.
> **Warnung Backslashes in `.properties`-Dateien:**
> In Java-`.properties`-Dateien werden Backslashes (`\`) als Escape-Zeichen interpretiert.
> Windows-Pfade wie `C:\Users\Funny\Logs` müssen entweder mit Forward-Slashes
> (`C:/Users/Funny/Logs`) oder mit doppelten Backslashes (`C:\\Users\\Funny\\Logs`)
> angegeben werden. Einfache Backslashes werden stillschweigend falsch interpretiert.
Betroffene Parameter:
| Parameter | Empfehlung |
|---|---|
| `log.directory` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/logs` |
| `runtime.lock.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock` |
| `prompt.template.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/prompts/template.txt` |
| `sqlite.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/pdf-umbenenner.db` |
Das empfohlene Konfigurationsverzeichnis für alle schreibbaren Daten im MSI-Betrieb ist
`C:\ProgramData\PDF KI Renamer\`, da dieses Verzeichnis standardmäßig für alle
Benutzerkonten schreibbar ist und bei der Deinstallation erhalten bleibt.
**Diagnose: Log-Datei-Prüfpunkt in den technischen Tests**
Die technischen Tests (Schaltfläche „Technische Tests ausführen" im Konfigurationseditor)
enthalten einen dedizierten Prüfpunkt **„Log-Verzeichnis beschreibbar"**, der anzeigt:
- den konfigurierten `log.directory`-Wert (roh und als aufgelöster absoluter Pfad),
- ob das Verzeichnis vorhanden und beschreibbar ist,
- den tatsächlichen Log-Dateipfad aus der laufenden Log4j2-Konfiguration.
Ein nicht beschreibbares Log-Verzeichnis wird als **Warnung** angezeigt, nicht als Fehler
(die Anwendung kann ohne Datei-Logging laufen). Der Prüfpunkt hilft, den typischen
MSI-Betriebsfehler relatives `log.directory` auf schreibgeschütztem Installationspfad
frühzeitig zu erkennen.
### MSI-Release-Checkliste
Die folgende Checkliste ist vor jeder MSI-Auslieferung manuell abzuarbeiten.
- [ ] Neuinstallation auf sauberer Windows-Umgebung ohne vorinstalliertes Java
- [ ] Installation in Installationspfad **mit Leerzeichen** (z. B. `C:\Program Files\PDF KI Renamer\`)
- [ ] Upgrade von installiertem Vorgänger-MSI (kein manuelles Deinstallieren)
- [ ] GUI-Start über Startmenü-Eintrag
- [ ] Headless-Start über `PDF-KI-Renamer.bat` im Windows Task Scheduler
- [ ] Desktop-Shortcut vorhanden oder Einschränkung hier dokumentiert
- [ ] App-Version `3.0.x` im Windows-Installer sichtbar („Programme und Features")
- [ ] Deinstallation sauber Konfiguration unter `C:\ProgramData\PDF KI Renamer\` bleibt erhalten
- [ ] SmartScreen-Warnung erscheint und wird durch „Weitere Informationen → Trotzdem ausführen" bestätigt
- [ ] BAT-Dateien funktionieren bei Installationspfad mit Leerzeichen
- [ ] Anwendungsstart **ohne Entwicklungs-JDK** erfolgreich: GUI-Start, PDF laden und rendern, Verarbeitungslauf starten, Verlaufs-Tab öffnen (Verifikation der `addModules`-Liste)
> **Hinweis zur JDK-freien Laufzeit-Verifikation:** Nur ein erfolgreicher Test
> auf einem System ohne installiertes JDK bestätigt die Vollständigkeit der
> `addModules`-Liste in `pdf-umbenenner-packaging/pom.xml`. Die aktuelle Liste
> wurde per `jdeps --print-module-deps --ignore-missing-deps` ermittelt;
> vollständige Ausgabe in `pdf-umbenenner-packaging/jdeps-output.txt`.
### Build-Kommandos
**Vollständiger Reactor-Build** (alle Module, Tests, Packaging):
@@ -514,6 +760,48 @@ Auf Unix-Systemen (headless CI):
---
## GUI: Selektive Wiederverarbeitung und Status-Reset
Die GUI ermöglicht nach Abschluss eines Verarbeitungslaufs zwei zusätzliche Aktionen auf der Ergebnisliste:
### Selektion in der Ergebnisliste
Die Ergebnisliste enthält eine **Checkbox pro Zeile** sowie eine **Master-Checkbox** zum Auswählen aller Einträge.
- Auswahl erfolgt wie im Windows Explorer mit **Shift/Strg-Mehrfachselektion**
- Alle vier Statustypen sind selektierbar: erfolgreich, retryable, permanent fehlgeschlagen, übersprungen
- Während eines Laufs ist die Selektion **gesperrt**
### Button „Erneut verarbeiten"
**Aktion:** DB-Status zurücksetzen + sofortiger Mini-Lauf nur für ausgewählte Dateien.
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
- Der Mini-Lauf arbeitet auf einem Snapshot der beim Klick ausgewählten Einträge
- Nicht ausgewählte Einträge bleiben unverändert in der Liste
- Verhalten identisch zu regulärem Lauf (gleiche Anwendungslogik, nur eingeschränkte Dateimenge)
**Besonderheit bei identischem Zieldateinamen:** Verarbeitet der KI-Provider wieder denselben Dateinamen wie ein vorangegangener erfolgreicher Lauf, erhält der Eintrag **Status erfolgreich** es wird keine erneute Kopie erzeugt, kein Fehler.
**Fehlende Quelldatei:** Ist die Datei zum Zeitpunkt des Mini-Laufs nicht mehr vorhanden, erhält der Eintrag **Status permanent fehlgeschlagen** mit Meldung „Quelldatei nicht gefunden".
### Button „Status zurücksetzen"
**Aktion:** Nur DB-Status zurücksetzen, keine sofortige Verarbeitung.
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
- Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt wartet auf nächsten Lauf"**
- Beim nächsten regulären Lauf werden zurückgesetzte Dateien automatisch mitgenommen
- **Best-effort-Reset:** Erfolgreiche und fehlgeschlagene Resets werden pro Eintrag einzeln durchgeführt; Zusammenfassung zeigt Erfolge und Fehler
### Verhalten während eines Mini-Laufs
- Der **Abbrechen-Button** gilt auch für Mini-Läufe (Soft-Stop)
- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt**
- Nach Soft-Stop: bereits verarbeitete Einträge behalten neuen Status, noch nicht gestartete zurückgesetzte Einträge warten auf nächsten regulären Lauf
- Fortschrittsbalken zeigt Fortschritt für die ausgewählte Dateimenge
---
## Weitere Dokumentation
Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md) beschrieben.
@@ -525,8 +813,8 @@ Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md)
- Nur OCR-verarbeitete, durchsuchbare PDF-Dateien werden verarbeitet
- Keine eingebaute OCR-Funktion
- Kein Web-UI, keine REST-API
- Die GUI (V2.0) dient der Konfiguration, Validierung und technischen Diagnose **kein** manueller Verarbeitungslauf aus der GUI
- Kein interner Scheduler der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`)
- 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 im headless Betrieb der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`); im GUI-Modus steht optional ein interner Scheduler zur Verfügung (Tab „Scheduler")
- Quelldateien werden nie überschrieben, verschoben oder gelöscht
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
- Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet
+8 -2
View File
@@ -99,8 +99,14 @@ max.pages=10
# Werte bis 1000: unkritisch.
# Werte 1001-3000: erhoehte KI-Kosten moeglich (Warnung in der GUI).
# Werte ab 3001: deutlich erhoehte KI-Kosten moeglich (starke Warnung in der GUI).
# Standardvorlage der GUI: 5000.
max.text.characters=5000
# Standardvorlage der GUI: 1000.
max.text.characters=1000
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
# Werte unter 10 oder ueber 120 verhindern den Start.
# Werte 10-19: Warnung (fuer die meisten Dokumente nicht empfohlen).
# Werte 100-120: Warnung (Dateiname wird sehr lang, Kompatibilitaet mit verschluesselten Volumes pruefen).
max.title.length=60
# ---------------------------------------------------------------------------
# Optionale Parameter
+112
View File
@@ -0,0 +1,112 @@
# V2.9-Freigabe
## Geprüfter Stand
- Git-Branch: `main`
- Git-Commit (HEAD, zum Zeitpunkt der Prüfung): `6ff463b7efd935960c246dd48f9c55906699a82d`
- Datum der Prüfung: 2026-04-28
---
## Umfang gegenüber V2.0
V2.9 ist die erste umfangreiche Funktionserweiterung nach dem V2.0-Abschluss.
Der Schwerpunkt liegt auf dem neuen Tab „Verarbeitungslauf", der PDF-Vorschau,
dem editierbaren Dateinamen-Bereich und der Kommunikation von Verarbeitungsergebnissen
an den Benutzer.
### Neu in V2.9
| Thema | Issues | Beschreibung |
|---|---|---|
| Tab „Verarbeitungslauf" (Grundstruktur) | #20, #21 | Zweiter Tab mit Ergebnistabelle, Detailbereich und PDF-Vorschau; Anwendungs-Icon und System-Tray |
| PDF-Vorschau (PDFBox-Migration) | #27, #29 | Direktes Rendering via `PDFRenderer.renderImageWithDPI`; Lazy Rendering mit In-Memory-Cache; Mausrad-Navigation |
| Vollbild-Start | #28 | `stage.setMaximized(true)` beim GUI-Start |
| Letzte Konfiguration automatisch laden | #33 | `java.util.prefs.Preferences` (`lastConfigPath`) |
| Historischer Dateiname für SKIPPED-Dokumente | #41 | Spalte „Neuer Dateiname" zeigt historischen KI-Vorschlag für übersprungene Einträge |
| Detailbereich für SKIPPED-Zeilen | #30 | `GuiHistoricalDocumentContextPort` liefert historischen Kontext; Detailbereich zeigt Datum, Name und Reasoning aus früherem Lauf |
| Manuelle Dateinamen-Eingabe (nicht verarbeitete Dateien) | #31 | Dateiname-Editor für `FAILED_RETRYABLE`, `FAILED_PERMANENT`, `SKIPPED_FINAL_FAILURE` zur manuellen Kopie |
| Benutzerfreundliche Fehlermeldungen | #43 | `AiFailureMessageTranslator` übersetzt technische Fehler für `FAILED`-Einträge ins Deutsche |
| Differenzierte Status-Icons mit Farben | #44 | Unicode-Symbole `✓ ↻ × ≡ ⊘ ⟳` mit farbiger CSS-Darstellung statt Emoji |
| Einzelinstanz-Schutz | #35 | Loopback-ServerSocket verhindert parallele Instanzen; zweite Instanz beendet sich sofort |
| UX-Fixes im Detailbereich | #39, #40, #45, #46, #47 | Abstände, Button-Deaktivierung, Hinweisbereich |
| Konfigurationsbereich kompakter | #24 | Layout-Optimierungen im Konfigurationstab |
| Legacy-Datumsformat-Behandlung | #48 | `stringToInstant()`-Fehlerbehandlung; korrekte Abschlussmeldung bei SKIPPED-only-Läufen |
| Prompt-Optimierung bei Zeichenlimit | #42 | Prompt weist KI explizit zur Kürzung auf konfiguriertes Zeichenlimit an |
---
## Ausgeführte Prüfungen
| Prüfung | Ergebnis |
|---|---|
| Vollständiger Maven-Reactor-Build (`clean verify`, alle 6 Module, `-DskipPitest=true`) | **ERFOLGREICH** |
| Unit-Tests gesamt | **siehe Tabelle** |
| Shaded-JAR erzeugt unter `pdf-umbenenner-bootstrap/target/` | **ja** |
| Architekturkonsistenz (kein JavaFX in Domain/Application, keine Adapter-zu-Adapter-Abhängigkeiten) | **ja** |
| Naming-Regel (keine M/AP/V-Bezeichner in Code) | **ja** |
| Dokumentation (`gui-bedienanleitung.md`, `betrieb.md`) auf Konsistenz mit Implementierung geprüft | **ja** |
---
## Build- und Test-Ergebnisse
Ausgeführtes Kommando:
```
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make -DskipPitest=true
```
**Gesamtergebnis: BUILD SUCCESS**
| Modul | Tests | Failures | Errors | Skipped |
|---|---|---|---|---|
| `pdf-umbenenner-domain` | 227 | 0 | 0 | 0 |
| `pdf-umbenenner-application` | 455 | 0 | 0 | 0 |
| `pdf-umbenenner-adapter-in-cli` | 8 | 0 | 0 | 0 |
| `pdf-umbenenner-adapter-in-gui` | 190 | 0 | 0 | 0 |
| `pdf-umbenenner-adapter-out` | 371 | 0 | 0 | 0 |
| `pdf-umbenenner-bootstrap` | 147 | 0 | 0 | 0 |
| **Gesamt** | **1.398** | **0** | **0** | **0** |
---
## Bekannte Einschränkungen
### #42 Prompt-Kürzungsverhalten modellabhängig
Der Prompt weist die KI explizit an, bei Überschreitung des konfigurierten Zeichenlimits
den Titel auf die zulässige Länge zu kürzen. Ob das Modell dieser Anweisung zuverlässig
folgt, hängt vom eingesetzten Modell ab. Modelle mit schwacher Instruction-Following-Fähigkeit
können das Limit ignorieren; in diesem Fall greift die bestehende serverseitige
Validierung und der Versuch wird als Fehler klassifiziert.
---
## Offene Punkte (für nachfolgende Stufen)
Die folgenden Issues sind bekannt, aber nicht Release-Blocker für V2.9:
| Issue | Thema |
|---|---|
| #7 | Persistenz-Browser / Historienansicht in der GUI |
| #22 | Kosten-Tracking und Token-Anzeige |
| #23 | Weitere KI-Provider jenseits Claude / OpenAI-kompatibel |
| #32 | Platzhalterbild in PDF-Vorschau bei fehlendem/ungültigem PDF |
| #34 | Dokumentation des Tab-„Verarbeitungslauf"-Bedienkonzepts vervollständigen |
| #44 | Icon-Farben unter bestimmten Windows-Systemthemen prüfen |
| #49 | Abbruch eines laufenden Verarbeitungslaufs aus der GUI |
| #50 | Fortschrittsanzeige während des Verarbeitungslaufs |
| #51 | Filter- und Sortierfunktion in der Ergebnistabelle |
---
## Freigabeaussage
V2.9 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
bleibt unverändert gegenüber V2.0. Keine Release-Blocker.
Der vollständige Maven-Reactor-Build ist grün (1.398 Tests, 0 Failures, 0 Errors,
0 Skipped). Die Dokumentation (`gui-bedienanleitung.md`, `betrieb.md`) ist auf
den V2.9-Stand gebracht. Die bekannte Einschränkung (#42) ist dokumentiert
und kein funktionaler Defekt.
+146
View File
@@ -0,0 +1,146 @@
# Freigabedokument V3.0 PDF-Umbenenner
## Geprüfter Stand
- Git-Branch: `main`
- Versionsnummer: `3.0.238`
- MSI-Datei: `PDF-KI-Renamer-3.0.238.msi`
- Freigabedatum: 2026-05-05
- **Status:** freigegeben
---
## Zielsetzung von V3.0
V3.0 ist kein Wechsel der Kernfunktion, sondern ein gezielter Qualitätssprung in drei
Dimensionen: **Infrastruktur** (konsistente Versionierung, Flyway-DB-Migration,
Jenkins-Stabilisierung, MSI-Vorbereitung), **Transparenz** (Historien-Tab, differenzierte
Fehlerstatus-Darstellung, Lauf-Summary-Banner) und **Bedienkomfort** (Tooltips, Statuszeile,
Prompt-Editor). Die fachliche Kernverarbeitung des PDF-Umbenenners PDF lesen, KI benennen,
Zieldatei kopieren bleibt vollständig unverändert. Es wird kein neues Maven-Modul eingeführt;
die hexagonale Architektur bleibt unangetastet.
---
## Umgesetzte Issues
| # | Commit | Kategorie | Beschreibung |
|---|---|---|---|
| #67 | `c6379c0` | Infrastruktur | Konsistente Versionierung via Maven CI-friendly `${revision}`, MANIFEST.MF mit `Implementation-Version`, Fallback `dev` |
| #68 | `500a8c5` | Infrastruktur | Jenkins-Build mit `-Drevision`-Übergabe, robuste Shade-JAR-Archivierung mit Bash und `mapfile` |
| #49 | `732d00c` | Infrastruktur | Flyway-Integration mit V1-Basisskript, 3-Fall-Strategie (leer / Bestand baselined / regulärer Folgestart), `PRAGMA foreign_keys` per `SQLiteConfig`, Lock-Mechanismus, vollständige Schema-Prüfcheckliste, manuelle Schema-Evolution entfernt |
| #51 | `563d9f5` | Fachlich/UX | Einheitliche Status-Darstellung mit Icon, Farbe, Tooltip; `FAILED_RETRYABLE` vs. `FAILED_FINAL` eindeutig differenziert |
| #66 | `0fe5359` | UX | Tooltips auf Konfigurationstab, Verarbeitungslauf-Tab und Toolbar; zentrale `GuiTooltipTexts`-Konstantenklasse |
| #73 | `dc17824` | GUI | Summary-Banner unterhalb Fortschrittsbalken nach Laufabschluss |
| #50 | `4f5ce4c` | GUI | Statuszeile mit Version, Provider/Modell und Konfigurationsdateipfad |
| #71 | `5d5dee0` | GUI | Prompt-Editor-Tab mit atomarem Speichern (`ATOMIC_MOVE`), Dirty-State, Default-Reset |
| #7 | `46fc1d4` | GUI | Historien-Tab mit Liste, Detail, Filter, Status-Reset (feldgenau, Versuche bleiben) und destruktivem Löschen (Attempts vor Record in Transaktion) |
| #65 | `51d6168` | Infrastruktur | MSI-Vorbereitung: jdeps-Modulliste, BAT-Dateien, `winUpgradeUuid`, Pfad-Hinweise in `betrieb.md` |
### Weitere Commits
| Commit | Beschreibung |
|---|---|
| `6e03093` | Architektur-Übersichten ergänzt (`domain-overview.md`, `gui-overview.md`, `adapter-overview.md`) |
| `4b89743` | Bedienanleitung auf neuen Stand gebracht |
---
## Architektur-Bilanz
| Neu | Anzahl | Bemerkung |
|---|---|---|
| Outbound-Ports | 1 | `HistoryQueryPort` |
| Application-Use-Cases | 5 | `DefaultPromptEditorUseCase`, `DefaultHistoryOverviewUseCase`, `DefaultHistoryDetailsUseCase`, `DefaultHistoryResetDocumentStatusUseCase`, `DefaultDeleteDocumentHistoryUseCase` |
| Outbound-Adapter | 2 | `SqliteHistoryQueryAdapter`, `FilesystemPromptPortAdapter.savePrompt` |
| GUI-Bridge-Interfaces | 5 | `GuiPromptEditorPort`, `GuiHistoryOverviewPort`, `GuiHistoryDetailsPort`, `GuiHistoryResetDocumentStatusPort`, `GuiDeleteDocumentHistoryPort` |
| GUI-Tabs | 2 | „Verlauf", „Prompt" |
| GUI-Komponenten | 5 | `GuiStatusBar`, `BatchRunSummaryBanner`, `GuiHistoryTab`, `GuiPromptEditorTab`, `ProcessingStatusPresentation` |
| Bootstrap | 1 + Erweiterung | `ApplicationVersionProvider` und Erweiterung des `GuiStartupContext` (`applicationVersion`, 5 neue Port-Felder) |
| Datenbank-Migration | | Flyway-V1-Basisskript, 3-Fall-Strategie, FK-Pragma per `SQLiteConfig`, Lock-Mechanismus |
Nicht geändert: `pdf-umbenenner-domain`, `pdf-umbenenner-adapter-in-cli`, headless-Betrieb.
Bootstrap ausschließlich um MANIFEST.MF-Einträge und neue Bridge-Verdrahtung erweitert.
---
## Verbindlich verifizierte Spec-Punkte
- `${revision}` wird durch `flatten-maven-plugin` (`resolveCiFriendliesOnly`) aufgelöst;
installierte POMs enthalten kein unaufgelöstes `${revision}`
- MANIFEST.MF im Fat-JAR trägt `Implementation-Version`; Laufzeit-Fallback ist `dev`
- `evolveTableColumns()` vollständig aus dem Code entfernt; Flyway ist die einzige
Schema-Evolutionsquelle
- Status-Reset setzt feldgenau `overall_status='READY_FOR_AI'`,
`content_error_count=0`, `transient_error_count=0`, `last_failure_instant=NULL`;
Versuche (`processing_attempt`) bleiben vollständig unangetastet
- Tab-Reihenfolge: `Konfiguration | Verarbeitungslauf | Verlauf | Prompt`
- `PromptPort.savePrompt` bleibt pfadfrei in der Port-Signatur (Hexagonal-konform;
Pfadauflösung liegt im Adapter)
- Farbe ist niemals das einzige Unterscheidungsmerkmal; alle Status tragen Icon und Text
---
## Headless-Kompatibilität
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten. Die
`.properties`-Datei bleibt die einzige Konfigurationswahrheit. GUI-Code initialisiert
den headless Pfad nicht. Keine stillen Änderungen an Retry-Semantik, Status-Persistenz
oder fachlicher Verarbeitungslogik.
---
## Datenbank-Migration
Bestehende Datenbestände aus dem Vorgängerstand werden beim ersten Start der 3-Fall-Strategie
unterworfen:
- **Neue DB** (keine Tabellen vorhanden): Flyway führt `V1__initial_schema.sql` vollständig aus.
- **Bestand ohne Flyway-History** (typische Vorgänger-DB): vollständige Schema-Prüfcheckliste
gegen das V1-Zielschema; bei konformem Schema wird eine datierte Backup-Kopie der
`.sqlite`-Datei erstellt, danach Baseline auf V1 gesetzt. Bei nicht konformem Schema
bricht der Start mit klarer Fehlermeldung ab kein stilles Weiterlaufen.
- **Bestand mit Flyway-History** (regulärer Folgestart): `migrate()` läuft idempotent.
`baselineOnMigrate=true` wird ausschließlich in Fall 2 gesetzt.
---
## Offene Punkte (vor finalem Release)
| Thema | Beschreibung |
|---|---|
| MSI-Testmatrix | Manueller MSI-Build und vollständige Abarbeitung der Testmatrix auf Windows-Maschine erforderlich; insbesondere Anwendungsstart **ohne JDK** zur Verifikation der `addModules`-Liste |
| `winUpgradeUuid` | Der GUID `EA8D0149-1401-4D3D-A98D-A2B98DAE5495` wurde im Rahmen von #65 neu generiert. Vor dem ersten produktiven MSI-Release ist sicherzustellen, dass kein bisheriges produktives MSI mit einem abweichenden GUID ausgeliefert wurde andernfalls bricht der MSI-Upgrade-Mechanismus. Nach Bestätigung „nie produktiv ausgeliefert" ist der GUID damit gesetzt und darf nie wieder geändert werden. |
| Manueller GUI-Produkttest | Erfolgreicher Build und grüne Tests ersetzen keinen End-to-End-Lauf gegen einen echten KI-Provider mit echten PDFs. |
| Finale Freigabe | `freigabe-v3_0.md` wird nach abgeschlossenem manuellen Produkttest und MSI-Verifikation in den Status „freigegeben" überführt. |
---
## Nicht in V3.0
- Automatischer Scheduler / Quellordner-Überwachung
- Token- und Kosten-Tracking
- Excel-Export
- Automatische Update-Prüfung
- Dark Mode
- Log-Viewer
- PDF-Viewer Render-DPI-Konfiguration
- Zoom per Mausrad
- Hilfe-Datei F1
- Änderungen an der fachlichen Kernverarbeitung des PDF-Umbenenners
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
---
## Freigabeaussage
V3.0 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
bleibt unverändert gegenüber dem Vorgängerstand. Keine Release-Blocker für die
Implementierungs-Freigabe.
Die finale Release-Freigabe steht aus bis zur vollständigen Abarbeitung der
MSI-Testmatrix (insbesondere Verifikation des Anwendungsstarts ohne JDK),
Klärung des `winUpgradeUuid`-Erstauslieferungsstatus und abgeschlossenem
manuellem GUI-Produkttest gegen einen echten KI-Provider.
+166
View File
@@ -0,0 +1,166 @@
# Freigabedokument V3.1 PDF-Umbenenner
## Geprüfter Stand
- Git-Branch: `main`
- Versionsnummer: `3.1.267`
- Freigabedatum: 2026-05-06
- **Status:** freigegeben
---
## Zielsetzung von V3.1
V3.1 ist der konsequente Nachschlag zu V3.0: Was der Produkttest aufgedeckt hat,
wird bereinigt. Kein großes Architektur-Feature, kein neues Maven-Modul
gezielter UX-Schliff und Robustheit in drei Schwerpunkten:
1. **UX-Polishing** sichtbare Schwächen aus dem V3.0-Produkttest behoben
(#77, #80, #81, #83, #84, #88, #91)
2. **Verlauf-Tab reifen lassen** Suche, Mehrfachauswahl, DB-Neuanlage
(#82, #86, #87)
3. **Quick Win** Mausrad-Zoom im PDF-Viewer als wertvoller Gebrauchskomfort
(#32)
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt vollständig unverändert.
Hexagonale Architektur, Modulstruktur, headless-Betrieb, `.properties`-
Konfigurationswahrheit und Flyway-DB-Evolution bleiben unangetastet.
---
## Umgesetzte Issues
| # | Kategorie | Beschreibung |
|---|---|---|
| #32 | GUI | Strg+Mausrad-Zoom in der PDF-Vorschau: Delta-Akkumulation für Trackpad-Kompatibilität, ScrollEvent bei Strg immer konsumiert, Zoom 10500 %, Viewport-Mitte bleibt beim Zoom stabil, Fit-to-Width-Modus nach manuellem Zoom verlassen; Grab & Pan mit Handcursor im vergrößerten Zustand |
| #77 | UX | Vollständige Bestandsaufnahme aller interaktiven Elemente auf allen Tabs; fehlende Tooltips auf allen vier Tabs ergänzt; neue Konstanten ausschließlich in `GuiTooltipTexts`; TableColumn-Header über Column-Graphic-Pattern mit Label und Tooltip (kein Skin-/Lookup-Hack) |
| #80 | UX | Dirty-Indikator für den Konfigurations-Tab: Asterisk im Tab-Titel bei echter Nutzeränderung gegenüber Baseline-Snapshot; `loadingInProgress`-Flag verhindert unechte Dirty-State-Auslösung durch programmgesteuertes Laden; Bestätigungsdialog beim Verlassen mit ungespeicherten Änderungen; Kopplung mit DB-Pfad-Wechsel aus #87 |
| #81 | UX | Status-ComboBox und Versuche-Tabelle zeigen lesbare deutsche Anzeigetexte statt Enum-Rohnamen; alle acht Statuswerte über `ProcessingStatusPresentation` abgebildet; Status-ComboBox mit „Alle Status" als GUI-internem Null-Filter; DB-Queries intern weiterhin mit Enum-Namen |
| #82 | GUI | Live-Filter im Verlauf-Tab: 300 ms Debounce-Timer, Generation-Counter für Race-Condition-Schutz, veraltete Worker-Ergebnisse werden verworfen; Such-Button und Enter starten Suche sofort; Auswahl nach jeder neuen Suche vollständig geleert |
| #83 | UX | Leere KI-Begründung im Detailbereich zeigt `promptText`-Platzhalter statt leerem Feld; kein Vermischen von Nutzdaten und UI-Platzhaltertext; TextArea bleibt sichtbar |
| #84 | Bug | Aktionsbuttons im Verlauf-Tab werden nach Laufende ereignisgetrieben reaktiviert unabhängig vom Terminierungsgrund (Erfolg, Fehlerabbruch, Nutzerabbruch, Leerlauf); kein manueller Workaround notwendig |
| #86 | GUI | Mehrfachauswahl im Verlauf-Tab: `SelectionMode.MULTIPLE`, Strg+A nur bei Tabellenfokus (kein Konflikt mit Suchfeld), Schlüssel-Snapshot vor Worker-Thread-Start, Bulk-Reset und Bulk-Delete mit Bestätigungsdialog und Partial-Success-Zusammenfassung; Detailbereich zeigt Platzhalter bei Mehrfachauswahl |
| #87 | GUI | Neuer Menüpunkt „Datenbank → Neue Datenbank anlegen...": atomarer Ablauf via Temp-Datei, Flyway auf neuesten Schema-Stand, Verbindungstest, atomarer Move mit `ATOMIC_MOVE + REPLACE_EXISTING`; normalisierter case-insensitiver Pfadvergleich; DB-Busy-Sperre; Konfig-Tab wechselt in Dirty-State; Hinweismeldung nach Wechsel |
| #88 | UX | Fehlerursache für `FAILED_FINAL`, `FAILED_RETRYABLE` und `SKIPPED_FINAL_FAILURE` im Verlauf-Tab sichtbar; Flyway-Migration ergänzt Spalte `failure_details` in `processing_attempt`; Begrenzung auf 1000 Zeichen mit „…"-Kürzung vor Persistierung; keine rohen Provider-Meldungen oder API-Schlüssel persistiert; NULL-Einträge zeigen `promptText`-Platzhalter |
| #91 | Robustheit | Lock-File-Pfadauflösung: absoluter Pfad direkt ohne Fallback (Abbruch bei Fehler); relativer oder unkonfigurierter Pfad zweistufig (JAR-Verzeichnis → `user.home` → Abbruch); fehlende Parent-Verzeichnisse automatisch angelegt; tatsächlich verwendeter absoluter Pfad beim Start auf INFO-Level geloggt; gilt für GUI- und headless Start |
### Nachbesserung aus dem Produkttest
| # | Beschreibung |
|---|---|
| #93 | Produkttest-Nachbesserung: Korrekturen und Feinabstimmungen nach abgeschlossenem manuellem GUI-Produkttest gegen echte KI-Provider und echte PDFs |
---
## Architektur-Bilanz
| Neu | Anzahl | Bemerkung |
|---|---|---|
| Inbound-Port-Interfaces | 1 | `CreateNewDatabaseUseCase` |
| Application-Use-Cases | 1 | `DefaultCreateNewDatabaseUseCase` |
| Outbound-Ports | 2 | `DatabaseCreationPort`, `ActiveDatabaseContextPort` |
| Outbound-Adapter | 2 | `SqliteDatabaseCreationAdapter`, `SqliteActiveDatabaseContextAdapter` |
| GUI-Bridge-Interfaces | 1 | `GuiCreateNewDatabasePort` |
| Flyway-Migration | 1 | `failure_details TEXT` in `processing_attempt` (nächste freie Versionsnummer) |
Geänderte Komponenten (ausschließlich `adapter-in-gui`):
`GuiHistoryTab`, `GuiConfigTab`, `GuiTooltipTexts`, Verlauf-Detailbereich,
Status-ComboBox, PDF-Vorschau-Komponente, Lauf-Abschluss-Signalkette.
Nicht geändert: `pdf-umbenenner-domain` (außer ggf. minimaler Erweiterung für #88),
`pdf-umbenenner-adapter-in-cli`, headless-Verarbeitungslogik, fachliche Kernverarbeitung.
---
## Verbindlich verifizierte Spec-Punkte
- Kein Enum-Rohname in der GUI sichtbar alle acht Statuswerte tragen Displaytext
- `promptText` für leere Felder: kein Vermischen von Nutzdaten und Platzhaltertext
- Dirty-State Konfig-Tab: programmgesteuertes Laden löst kein Dirty-Flag aus
- Live-Filter: 300 ms Debounce, Generation-Counter, Auswahl nach Suche geleert
- Strg+A im Verlauf-Tab: nur bei Tabellenfokus (kein Konflikt mit Suchfeld)
- Schlüssel-Snapshot vor Bulk-Worker-Thread-Start
- DB-Anlage: normalisierter Pfadvergleich (case-insensitive, `toRealPath`/Parent-Normalisierung)
- DB-Anlage: `ATOMIC_MOVE + REPLACE_EXISTING`; kein halb-atomarer Fallback
- DB-Anlage: aktive DB bleibt bei Fehler vollständig unverändert
- Lock-File: absoluter Pfad direkt; relativer Pfad zweistufig; Pfad geloggt (INFO)
- Strg+Mausrad: ScrollEvent immer konsumiert; Delta-Akkumulation; 10500 %
- `failure_details`: max. 1000 Zeichen vor Persistierung; keine rohen Provider-Meldungen
- Aktionsbuttons nach Laufende ereignisgetrieben reaktiviert (alle Terminierungsgründe)
- Flyway ist die einzige Schema-Evolutionsquelle kein manuelles DDL im Code
- Code-Kommentare auf Deutsch; Logging auf Deutsch
- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
---
## Headless-Kompatibilität
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten.
Die `.properties`-Datei bleibt die einzige Konfigurationswahrheit. GUI-Code
initialisiert den headless Pfad nicht. Keine stillen Änderungen an Retry-Semantik,
Status-Persistenz oder fachlicher Verarbeitungslogik.
Von V3.1-Änderungen betroffener headless-Pfad: Lock-File-Pfadauflösung (#91)
und Flyway-Schemamigration für `failure_details` (#88) beide wirken beim
Programmstart unabhängig von GUI oder CLI.
---
## Datenbank-Migration
Flyway ergänzt die Tabelle `processing_attempt` um die Spalte `failure_details`:
```sql
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
```
- Bestehende Zeilen erhalten automatisch `NULL` kein Datenverlust.
- Ältere Einträge ohne Fehlerdetails zeigen in der GUI einen `promptText`-Platzhalter.
- Kein SQL-`CHECK`-Constraint (um Importdaten nicht zu blockieren).
- Begrenzung auf 1000 Zeichen wird ausschließlich vor Persistierung im Adapter erzwungen.
---
## Produkttest
**Produkttest: bestanden**
Manueller GUI-Produkttest gegen echte KI-Provider mit echten PDFs abgeschlossen.
Alle elf Issues und die Nachbesserung #93 wurden end-to-end verifiziert.
---
## Bekannte Einschränkungen
Keine.
---
## Nicht in V3.1
- Automatischer Scheduler / Quellordner-Überwachung (#22) → V3.x
- PDF-Viewer Render-DPI (#23) → V3.2
- F1-Hilfe (#69) → V3.2
- Dark Mode (#70) → V3.x
- Log-Viewer in der GUI (#72) → V3.2
- Token- und Kosten-Tracking (#74) → V3.2
- Excel-Export (#75) → V3.2
- Automatische Update-Prüfung (#76) → V3.2
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
- Änderung der fachlichen Kernverarbeitung des PDF-Umbenenners
---
## Nächste Version
**V3.2** geplante Schwerpunkte: PDF-Viewer Render-DPI, F1-Hilfe, Log-Viewer,
Token- und Kosten-Tracking, Excel-Export, automatische Update-Prüfung.
---
## Freigabeaussage
V3.1 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
bleibt unverändert gegenüber V3.0. Manueller Produkttest bestanden.
Keine Release-Blocker.
+170
View File
@@ -0,0 +1,170 @@
# Freigabedokument V3.2 PDF-Umbenenner
## Geprüfter Stand
- Git-Branch: `main`
- Versionsnummer: `3.2.300`
- Freigabedatum: 2026-05-07
- **Status:** freigegeben
---
## Zielsetzung von V3.2
V3.2 ist der Übergang vom manuellen Batch-Tool zur autonomen
Dauerläufer-Anwendung. Ein einziges, klar abgegrenztes Hauptfeature:
**#22 Automatischer Scheduler:** Die Anwendung überwacht den konfigurierten
Quellordner dauerhaft im Hintergrund und startet die Verarbeitungspipeline
automatisch, sobald neue PDF-Dateien erkannt werden. Der Nutzer steuert
den Scheduler ausschließlich über den neuen Tab „Scheduler".
V3.2 ist eine reine Scheduler-Veranstaltung. Token- und Kosten-Tracking (#74)
wurde bewusst herausgelöst und bekommt eine eigene saubere Spezifikation in
V3.x inklusive Modell-Preistabelle, Persistenz-Strategie und EUR-Währung.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt vollständig
unverändert. Hexagonale Architektur, Modulstruktur, headless-Betrieb,
`.properties`-Konfigurationswahrheit und Flyway-DB-Evolution bleiben
unangetastet.
---
## Umgesetzte Features
| # | Kategorie | Beschreibung |
|---|---|---|
| #22 | Hauptfeature | Automatischer Scheduler: `ScheduledExecutorService`-Polling mit `scheduleWithFixedDelay`; Initial Delay 0 (erster Tick sofort); konfigurierbares Intervall (Minimum 30 s); neuer Tab „Scheduler" mit Start/Stop, Statusanzeige, Countdown, letzter Lauf, Gesamtzähler; OS-Lock auf `.properties` während Scheduler läuft; Konfig-Tab read-only bei aktivem Lock; manuelle Läufe bei aktivem Scheduler gesperrt; App-Schließen-Guard |
### Neue Architektur-Komponenten
| Neu | Anzahl | Bemerkung |
|---|---|---|
| Neues Maven-Modul | 1 | `pdf-umbenenner-adapter-in-scheduler` |
| Inbound-Port-Interfaces | 1 | `SchedulerControlUseCase` |
| Application-Use-Cases | 1 | `DefaultSchedulerControlUseCase` |
| Outbound-Ports | 3 | `SchedulerPort`, `ConfigurationFileLockPort`, `SchedulerSettingsPort` |
| Funktionale Interfaces | 1 | `BatchRunTrigger` mit sealed `BatchRunTriggerResult` |
| Neue Adapter | 2 | `ScheduledExecutorServiceSchedulerAdapter`, `FileChannelConfigurationAccessAdapter` |
| GUI-Komponenten neu | 2 | `GuiSchedulerTab`, `GuiStatusRefreshTimeline` |
| Bootstrap-Refactoring | | Init/Run-Trennung: `GuiShellContext` immer, `ApplicationRunContext` bei valider Config; `GuiApplicationContextInitializer`-Callback für Auto-Load-Pfad |
| Flyway-Migration | 0 | Keine DB-Migration in V3.2 |
Kontrollierte Architekturausnahme: CLAUDE.md wurde um die Scheduler-Ausnahme
erweitert. „Keine Dauerlauf-Anwendung" und „kein interner Scheduler" gelten
ab V3.2 nur noch für den headless-Pfad.
### Zusätzliche Verbesserungen (Produkttest-Nachbesserungen)
| Beschreibung |
|---|
| `ApplicationRunContext` wird nun auch beim Auto-Load-Pfad (ohne `--config`) korrekt aufgebaut via `GuiApplicationContextInitializer`-Callback |
| Double-Lock-Bug im `BatchRunTrigger`-Lambda behoben: kein eigenes `tryAcquire()` mehr, Lock ausschließlich in `execute()` |
| Stop-Button-Wiring-Bug behoben: `GuiStatusRefreshTimeline` liest jetzt den Live-Use-Case aus dem Workspace statt aus dem unveränderlichen `GuiStartupContext` |
| `installSchedulerCloseGuard` analog gefixt (gleiches Wiring-Problem) |
| `loadHistoryOverviewForGui` und 6 weitere GUI-Methoden im `BootstrapRunner` nutzen bei vorhandenem `ApplicationRunContext` direkt den Repository-Adapter statt Config neu zu laden verhindert IOException bei aktivem Config-Lock |
| Autostart-Feature entfernt: Scheduler startet nie automatisch, immer nur auf explizite Nutzeraktion |
| `RunSummary`-Zählung im Scheduler-Tab korrigiert: `PROPOSAL_READY` zählt korrekt als Erfolg; Gesamtzähler seit Scheduler-Start eingeführt |
| Java-Preferences-Knoten auf fixen String `de/gecheckt/pdf-umbenenner` umgestellt verhindert Verlust des gespeicherten Config-Pfads nach Code-Änderungen |
---
## Verbindlich verifizierte Spec-Punkte
- Scheduler startet nur auf explizite Nutzeraktion kein Autostart
- Erster Tick läuft sofort nach Scheduler-Start (Initial Delay 0)
- `scheduleWithFixedDelay`: nächster Tick erst N Sekunden nach Laufende
- Laufkollision via nicht-blockierendem `RunLockPort.tryAcquire()` kein Queuing
- Manuelle Läufe bei aktivem Scheduler gesperrt (deterministisches Verhalten)
- OS-Lock auf `.properties` während Scheduler läuft: Konfig-Tab read-only,
Speichern-Button deaktiviert, Eingabefelder nicht editierbar
- Verlauf-Tab funktioniert korrekt bei aktivem Config-Lock
- Stop während aktivem Lauf: Batch läuft zu Ende, danach `STOPPED`
- App-Schließen bei aktivem Scheduler: Hinweisdialog, App schließt nicht
- `SchedulerStatus` als immutable Snapshot via `AtomicReference`
- `SchedulerState` mit 5 Werten: `STOPPED`, `STARTING`, `RUNNING_IDLE`,
`RUNNING_BATCH_ACTIVE`, `STOPPING_BATCH_ACTIVE`
- No-op-Lauf (keine Kandidaten): „keine neuen Dokumente"; kein Fehlerstatus
- Scheduler-Tab zeigt korrekte Anzeige: letzter Lauf + Gesamtzähler
- Exception im Tick: gefangen, ERROR-geloggt, Executor läuft weiter
- Non-Daemon-Thread; sauberer Shutdown via `awaitTermination`
- Kein JavaFX im Modul `adapter-in-scheduler`
- PIT im neuen Modul explizit deaktiviert
- Code-Kommentare auf Deutsch; Logging auf Deutsch
- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
- Flyway ist die einzige Schema-Evolutionsquelle keine Migration in V3.2
---
## Headless-Kompatibilität
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten.
Scheduler-Properties (`scheduler.enabled`, `scheduler.interval.seconds`)
werden im headless-Modus weder gelesen noch validiert. Der headless-Pfad
verwendet keinen Scheduler-Codepfad und keinen Config-Lock.
---
## Datenbank-Migration
**Keine.** Das DB-Schema bleibt unverändert auf V1 (`V1__initial_schema.sql`).
Es wurden keine neuen Spalten und keine neuen Tabellen angelegt.
---
## Produkttest
**Produkttest: bestanden**
Manueller GUI-Produkttest gegen echten KI-Provider mit echten PDFs
abgeschlossen. Der Scheduler hat PDFs automatisch erkannt, per KI benannt
und in den Zielordner verschoben vollautomatisch ohne Nutzeraktion.
Alle wesentlichen Szenarien (Start/Stop, No-op-Lauf, aktive Verarbeitung,
Verlauf-Tab bei aktivem Scheduler, App-Schließen-Guard) wurden verifiziert.
---
## Bekannte Einschränkungen
| Einschränkung | Bewertung |
|---|---|
| JavaFX `NullPointerException` beim Schließen (`GraphicsPipeline.getPipeline() == null`) | JavaFX-interner Fehler nach Shutdown; kein Fehler im Anwendungscode; kein Datenverlust; kein Handlungsbedarf |
| Unvollständige PDFs (noch im Kopiervorgang) können temporär `FAILED_RETRYABLE` erzeugen | Erwartet; bestehende Retry-Semantik behandelt das korrekt beim nächsten Tick |
---
## Nicht in V3.2
- Token- und Kosten-Tracking (#74) → V3.x (eigene Spezifikation mit
Modell-Preistabelle, Persistenz-Strategie, EUR-Währung)
- Headless-Daemon-Betrieb des Schedulers (`--watch`-Flag) → V3.x
- Java WatchService (ereignisgesteuerte Ordnerüberwachung) → V3.x
- Windows-Service-Integration (WinSW o.ä.) → V3.x
- Modell-Filterung (OpenAI-Snapshots ausblenden) → V3.x
- Dark Mode (#70) → V3.x
- F1-Hilfe (#69) → V3.x
- Log-Viewer in der GUI (#72) → V3.x
- Excel-Export (#75) → V3.x
- Automatische Update-Prüfung (#76) → V3.x
- Neue KI-Provider, Architekturbrüche
- Änderung der fachlichen Kernverarbeitung des PDF-Umbenenners
---
## Nächste Version
**V3.x** Token- und Kosten-Tracking als eigenständiges, vollständig
durchdachtes Feature: Modell-Preistabelle (pro Modell, nicht pro Provider),
EUR-Währung, Kostenanzeige im Summary-Banner, Modell-Filterung für
OpenAI-kompatible Provider.
---
## Freigabeaussage
V3.2 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der
hexagonalen Architektur sind eingehalten. Das neue Modul `adapter-in-scheduler`
ist korrekt eingebunden (kein JavaFX, PIT deaktiviert, flatten aktiv).
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert
gegenüber V3.1. Headless-Betrieb vollständig unberührt. Manueller
Produkttest bestanden. Keine Release-Blocker.
+818 -26
View File
@@ -1,4 +1,4 @@
# GUI-Bedienanleitung PDF-Umbenenner V2.0
# GUI-Bedienanleitung PDF-Umbenenner
Diese Anleitung beschreibt die JavaFX-Desktop-GUI des PDF-Umbenenners. Sie richtet sich an
Endbenutzer und Betreuer, die die Konfiguration der Anwendung über die grafische Oberfläche
@@ -6,21 +6,25 @@ verwalten und technisch prüfen möchten.
---
## 1. Zweck und Scope der GUI in V2.0
## 1. Zweck und Scope der GUI
Die GUI dient in V2.0 ausschließlich als:
Die GUI gliedert sich in fünf feste Tabs:
- **Konfigurationseditor** für die `.properties`-Datei
- **Validierungsoberfläche** für den aktuellen Konfigurationsstand
- **Technische Test- und Diagnoseoberfläche** für Erreichbarkeit des Providers,
Pfadprüfungen und Ressourcenverfügbarkeit
- **Tab 1 „Konfiguration"** Editor, Validierungsoberfläche und technische
Test-/Diagnoseoberfläche für die `.properties`-Datei.
- **Tab 2 „Verarbeitungslauf"** Start eines Batch-Laufs aus der GUI mit
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
- **Tab 3 „Scheduler"** Optionaler automatischer Scheduler für periodische
Verarbeitungsläufe (siehe Abschnitt 14).
- **Tab 4 „Verlauf"** Ansicht aller bisher verarbeiteten Dokumente mit Status
und Verarbeitungsdetails aus der SQLite-Datenbank (siehe Abschnitt 17).
- **Tab 5 „Prompt"** Editor zum Lesen, Bearbeiten und Speichern der
konfigurierten KI-Prompt-Datei (siehe Abschnitt 18).
Die GUI enthält in V2.0 **keinen** manuellen Verarbeitungslauf. Das Starten eines
Batch-Laufs aus der GUI ist erst ab einer späteren Ausbaustufe vorgesehen.
Ebenso gibt es keinen Historien-Tab, keine Datenbankansicht und kein Kosten-Tracking.
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 19).
Der headless Batch-/Scheduler-Betrieb über `--headless` bleibt der einzige Weg,
PDF-Dateien automatisiert zu verarbeiten.
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
`--headless` der empfohlene Weg.
---
@@ -28,12 +32,17 @@ PDF-Dateien automatisiert zu verarbeiten.
### 2.1 GUI-Standardstart
Wird die Anwendung ohne CLI-Argumente gestartet, öffnet sich die JavaFX-Desktop-GUI.
Es wird keine Konfigurationsdatei automatisch geladen.
Wird die Anwendung ohne CLI-Argumente gestartet, öffnet sich die JavaFX-Desktop-GUI
**maximiert** (Vollbild).
Stattdessen zeigt die GUI einen deutschen Willkommenstext mit dem Hinweis, über
„Neu" eine Standardvorlage zu erzeugen oder über „Öffnen" eine bestehende
`.properties`-Datei zu laden.
Wurde bei einem früheren Start eine Konfigurationsdatei geladen, wird diese automatisch
erneut geladen. Der zuletzt verwendete Pfad wird systemseitig gespeichert
(`java.util.prefs.Preferences`). Existiert die Datei nicht mehr, startet die GUI ohne
Fehlermeldung mit dem Willkommenstext — es erscheint kein Dialog und kein Fehler.
Beim allerersten Start (oder wenn noch keine Datei geladen wurde) zeigt die GUI einen
deutschen Willkommenstext mit dem Hinweis, über „Neu" eine Standardvorlage zu erzeugen
oder über „Öffnen" eine bestehende `.properties`-Datei zu laden.
### 2.2 Start mit `--config <pfad>` (gültige Datei)
@@ -78,10 +87,9 @@ verworfen werden.
### 3.2 Zentraler Meldungsbereich
Am unteren Ende der GUI befindet sich ein großer, nicht editierbarer
Meldungsbereich. Er ist dauerhaft sichtbar und zeigt Ergebnisse von
Validierungen, technischen Tests, Migrationsmeldungen und sonstige
Statusinformationen.
Am unteren Ende der GUI befindet sich ein großer Meldungsbereich. Er ist
dauerhaft sichtbar und zeigt Ergebnisse von Validierungen, technischen Tests,
Migrationsmeldungen und sonstige Statusinformationen.
Der Meldungsbereich verwendet vier feste Stufen:
@@ -96,6 +104,39 @@ Nur das Präfix am Zeilenanfang wird farbig dargestellt. Der eigentliche
Meldungstext derselben Zeile ist immer schwarz. Die vier Stufen dienen
ausschließlich der visuellen Einordnung; sie verhindern das Speichern nicht.
#### Meldungen kopieren
Einzelne oder mehrere Meldungen können markiert und in die Zwischenablage
kopiert werden:
- **Einzelne Zeile markieren:** Meldung anklicken
- **Mehrere Zeilen markieren:** Shift+Klick (Bereich) oder Strg+Klick (Einzelauswahl)
- **Alle Zeilen markieren:** Strg+A
- **Markierte Zeilen kopieren:** Strg+C
Per Rechtsklick steht zusätzlich ein Kontextmenü zur Verfügung:
| Eintrag | Wirkung |
|---------|---------|
| **Meldung kopieren** | Kopiert alle markierten Zeilen in die Zwischenablage (nur aktiv, wenn eine Auswahl besteht) |
| **Alle Meldungen kopieren** | Kopiert alle aktuell angezeigten Meldungen in die Zwischenablage |
#### Meldungen leeren
Unterhalb des Meldungsbereichs befindet sich links der Button **„Meldungen leeren"**.
Ein Klick darauf entfernt alle aktuell angezeigten Meldungen sofort und
vollständig.
Darüber hinaus wird der Meldungsbereich in folgenden Situationen automatisch
geleert, sodass keine Meldungen aus einem früheren Vorgang sichtbar bleiben:
| Aktion | Verhalten |
|--------|-----------|
| **Neu** | Meldungsbereich wird vor der neuen Konfiguration geleert |
| **Öffnen** | Meldungsbereich wird vor der geladenen Konfiguration geleert |
| **Validieren** | Meldungsbereich wird geleert; danach erscheinen ausschließlich die Befunde des aktuellen Durchlaufs |
| **Technische Tests ausführen** | Meldungsbereich wird geleert; danach erscheinen ausschließlich die Befunde des aktuellen Durchlaufs |
---
## 4. Aktionen
@@ -244,8 +285,24 @@ Wirtschaftliche Warnschwellen für `max.text.characters`:
| 1.001 3.000 | Warnung |
| ab 3.001 | starke Warnung |
**Standard-Default der GUI-Vorlage:** 1.000 Zeichen (unkritisch)
`max.pages` wird als Plausibilitäts- und Performance-Hinweis behandelt.
Validierungsregeln für `max.title.length` (Feld „Max. Titellänge (Zeichen)" im Bereich „Verarbeitungslimits"):
| Wertebereich | Bewertung |
|---|---|
| Kein Wert / leer | Fehler Pflichtfeld, Konfiguration nicht lauffähig |
| Keine Ganzzahl (z. B. „abc") | Fehler ungültiger Typ |
| Kleiner als 10 | Fehler Minimum ist 10 Zeichen |
| 10 39 | Warnung Titellänge unter 40 Zeichen KI-Ergebnisse können unvollständig sein, da Absender allein bereits 1520 Zeichen benötigt |
| 40 99 | Normaler Betrieb, keine Meldung |
| 100 120 | Warnung Hohe Titellänge Kompatibilität mit verschlüsselten Volumes prüfen |
| Größer als 120 | Fehler überschreitet sicheres Limit für verschlüsselte Volumes |
Warnungen verhindern das Speichern nicht. Fehler markieren den Stand als nicht lauffähig; Speichern ist dennoch erlaubt, jedoch erscheint ein deutlicher Hinweis im Meldungsbereich.
---
## 7. Provider-Bedienung und Modellabruf
@@ -282,13 +339,23 @@ vorbelegt.
## 8. Dirty-State und Schutzdialoge
### Konfigurations-Tab
Sobald eine geladene oder neu erzeugte Konfiguration bearbeitet wird, gilt der
Editor als „dirty" (ungespeicherte Änderungen). Zwei visuelle Markierungen
Editor als „dirty" (ungespeicherte Änderungen). Drei visuelle Markierungen
zeigen diesen Zustand an:
- Ein **`*`**-Präfix im **Tab-Titel**: `* Konfiguration`
- Ein **`*`**-Präfix im Fenstertitel
- Ein kleines **„geändert"**-Label im Header
Das Dirty-Flag wird über einen **Baseline-Snapshot** ermittelt: Beim Laden einer
Konfiguration wird ein Snapshot des geladenen Zustands gespeichert. Erst wenn
der aktuelle Formularinhalt vom Snapshot abweicht, erscheint der Dirty-Indikator.
Programmgesteuertes Laden und Normalisieren von Feldinhalten lösen keinen
Dirty-State aus. Auch ein DB-Pfad-Wechsel über „Neue Datenbank anlegen..."
(Abschnitt 17a) versetzt den Konfigurations-Tab in den Dirty-State.
Vor den Aktionen „Neu", „Öffnen" und beim Schließen des Fensters prüft die GUI,
ob ungespeicherte Änderungen vorhanden sind. Ist dies der Fall, erscheint ein
Schutzdialog mit drei Optionen:
@@ -299,6 +366,12 @@ Schutzdialog mit drei Optionen:
| **Verwerfen** | Verwirft die Änderungen und führt die Aktion aus |
| **Abbrechen** | Bricht die Aktion ab; die Änderungen bleiben erhalten |
### Prompt-Tab
Der Prompt-Tab zeigt ebenfalls ein Asterisk im Tab-Titel (`Prompt *`), sobald der
TextArea-Inhalt vom gespeicherten Stand abweicht. Das Verhalten ist identisch zum
Konfigurations-Tab (Schutzdialog, Reset nach Speichern).
---
## 9. `.bak`-Sicherung beim Überschreiben und Legacy-Migration
@@ -393,13 +466,732 @@ Die GUI wird offiziell nur unter **Windows** unterstützt.
---
## 13. Bekannte Einschränkungen V2.0
## 13. Tab „Verarbeitungslauf" (live-Verfolgung)
Der zweite Tab „Verarbeitungslauf" startet einen Batch-Lauf direkt aus der GUI und
zeigt dessen Fortschritt in Echtzeit an.
### Layout
- **Fortschrittsbalken** mit Zähler (`n / m Dateien`) im Kopfbereich
- **Ergebnisliste** (scrollbar) mit einer Zeile pro abgeschlossener Datei
- **Seitenbereich** rechts neben der Liste für die KI-Begründung
- **Meldungs- und Zusammenfassungsbereich** unter der Liste
- Aktionsknöpfe **Starten** und **Abbrechen**
### Konfigurationsquelle
Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der
`.properties`-Datei. Ungespeicherte Änderungen im Konfigurations-Editor fließen **nicht**
in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein.
### Start und Verlauf
- Beim Start wird die Dateimenge **einmalig** bestimmt; der Nenner des Fortschrittsbalkens
bleibt während des Laufs konstant.
- Nach jeder abgeschlossenen Datei erscheint ohne manuellen Refresh eine neue Zeile mit
den fünf Spalten **Status-Icon**, **Originaldateiname**, **Neuer Dateiname**, **Datum**
und **Dauer**.
- Für `FAILED_*`-Zeilen und `SKIPPED_FINAL_FAILURE`-Zeilen wird in den Spalten
„Neuer Dateiname" und „Datum" ein Gedankenstrich `—` eingetragen.
`SKIPPED_ALREADY_PROCESSED`-Zeilen zeigen in der Spalte „Neuer Dateiname" den
historischen Zieldateinamen aus dem letzten erfolgreichen Lauf; „Datum" bleibt `—`.
- Status-Icons (Unicode-Zeichen mit Farbe):
| Symbol | Farbe | Bedeutung |
|--------|-------|-----------|
| `✓` | Grün | Erfolgreich |
| `↻` | Orange | Fehlgeschlagen (wiederholbar) |
| `×` | Rot | Fehlgeschlagen (permanent) |
| `≡` | Grau | Übersprungen (bereits erfolgreich verarbeitet) |
| `⊘` | Dunkelgrau | Übersprungen (endgültig fehlgeschlagen) |
| `⟳` | Grau | Zurückgesetzt wartet auf nächsten Lauf |
Farbe ist niemals das einzige Unterscheidungsmerkmal Icon und Tooltip beschreiben
den Status auch ohne Farbwahrnehmung eindeutig. Die vollständige Status-Mapping-Tabelle
mit Tooltips ist in Abschnitt 20 beschrieben.
- Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge
zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des
KI-Reasonings. Liegt weder Reasoning noch Fehlermeldung vor, erscheint der
Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning vor.".
- Nach Laufende erscheint das **Summary-Banner** unterhalb des Fortschrittsbalkens
(siehe Abschnitt 13c).
### Soft-Stop
Der Knopf **Abbrechen** löst einen **Soft-Stop** aus: die aktuell in Bearbeitung
befindliche Datei wird vollständig fertig verarbeitet, anschließend wird der Lauf sauber
beendet — keine halbfertigen Zustände in der SQLite-Datenbank.
### Sperre von Tab 1 während eines Laufs
Während eines laufenden Verarbeitungslaufs ist Tab 1 „Konfiguration" gesperrt. Ein
sichtbarer Hinweis erinnert daran, dass die Konfiguration während des Laufs nicht
editierbar ist. Nach Abschluss, Abbruch oder einer unerwarteten Ausnahme wird Tab 1
automatisch wieder freigegeben.
### Fenster schließen während eines Laufs
Versucht der Benutzer das Fenster zu schließen, während ein Lauf aktiv ist, erscheint ein
Hinweisdialog mit zwei Optionen:
- **Nicht schließen** der Lauf läuft unverändert weiter
- **Lauf beenden und schließen** ein Soft-Stop wird ausgelöst; nach Abschluss der
aktuellen Datei schließt die Anwendung
### Grenzen und Hinweise
- Pro Anwendungsinstanz ist genau **ein** Verarbeitungslauf gleichzeitig zulässig. Ein
zweiter Startversuch während eines laufenden Laufs wird mit der Meldung „Ein
Verarbeitungslauf ist bereits aktiv." verweigert.
- Ein **gleichzeitiger externer headless Lauf** (Windows Task Scheduler) wird weder
aktiv erkannt noch technisch geblockt. Der Benutzer ist selbst verantwortlich,
parallele Läufe zu vermeiden.
- Startet der Lauf mit einem leeren Quellordner, erscheint der Hinweis „Keine
verarbeitbaren Dateien im Quellordner gefunden" und die Zusammenfassung
`0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen` wird eingetragen.
---
## 13a. Selektion, Wiederverarbeitung und Status-Reset (V2.8)
Nach Abschluss eines Verarbeitungslaufs können einzelne oder mehrere Dateien aus der
Ergebnisliste gezielt erneut verarbeitet oder deren Status zurückgesetzt werden.
### Selektion in der Ergebnisliste
- Jede Zeile hat eine **Checkbox** am linken Rand
- Zusätzlich eine **Master-Checkbox** oberhalb der Liste zum Auswählen/Abwählen aller Einträge
- **Zeilenklick** (auf Text/Status-Icon) repräsentiert dieselbe Selektionsmenge wie die Checkbox
- **Shift/Strg-Mehrfachselektion** funktioniert wie im Windows Explorer
- Shift+Klick: Bereich vom letzten zur aktuellen Zeile
- Strg+Klick: einzelne Zeilen hinzufügen/entfernen
- Alle vier Statustypen sind selektierbar: ✅ erfolgreich, ⚠️ retryable, ❌ permanent, ⏭️ übersprungen
- Die Selektion bleibt nach Aktionen erhalten, bis ein neuer Lauf gestartet wird
### Button „Erneut verarbeiten"
**Wann nutzen:** Der KI-Prompt wurde geändert, das Modell gewechselt oder die Verarbeitung einer Datei
muss aus anderen Gründen wiederholt werden und das Ergebnis soll sofort verfügbar sein.
**Was passiert:**
1. Wird ein Button-Klick ausgelöst, wird die aktuelle Selektion als **Snapshot** erfasst
2. Der DB-Status aller selektierten Einträge wird zurückgesetzt
3. Ein **Mini-Lauf** startet sofort und verarbeitet nur diese Dateien
4. Unselektierte Einträge bleiben unverändert in der Liste
5. Die Mini-Lauf-Ergebnisse werden live in den selektierten Zeilen aktualisiert
**Besonderheiten:**
- Verarbeitet die KI wieder denselben Dateinamen wie der vorherige erfolgreiche Lauf,
erfolgt **keine erneute Kopie** der Eintrag erhält Status ✅ erfolgreich
- Ist die Quelldatei nicht mehr vorhanden, erhält der Eintrag Status ❌ permanent fehlgeschlagen
mit Meldung „Quelldatei nicht gefunden"
**Button-Status:**
- **Aktiv:** kein Lauf aktiv UND mindestens 1 Eintrag selektiert
- **Inaktiv:** Lauf läuft ODER keine Selektion
### Button „Status zurücksetzen"
**Wann nutzen:** Eine Datei soll später erneut verarbeitet werden, aber nicht sofort z. B. nach
Behebung eines externen Fehlers oder planmäßig im nächsten regulären Lauf.
**Was passiert:**
1. Der DB-Status aller selektierten Einträge wird zurückgesetzt
2. Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt wartet auf nächsten Lauf"**
3. **Kein sofortiger Mini-Lauf**
4. Beim nächsten regulären Lauf werden diese Dateien automatisch mitgenommen
**Fehlerbehandlung (Best-effort):**
- Resets werden pro Eintrag einzeln durchgeführt
- Erfolgreiche und fehlgeschlagene Resets werden separat gezählt
- Zusammenfassung im Meldungsbereich zeigt:
- Anzahl ausgewählter Einträge
- Anzahl erfolgreich zurückgesetzt
- Anzahl fehlgeschlagen + betroffene Dateinamen
**Button-Status:**
- **Aktiv:** kein Lauf aktiv UND mindestens 1 Eintrag selektiert
- **Inaktiv:** Lauf läuft ODER keine Selektion
### Verhalten während eines Mini-Laufs
- Der **Abbrechen-Button** löst einen Soft-Stop auch für Mini-Läufe aus:
- bereits verarbeitete Einträge behalten ihren neuen Endstatus
- noch nicht gestartete, aber bereits zurückgesetzte Einträge erhalten Status
„Zurückgesetzt wartet auf nächsten Lauf" und werden beim nächsten regulären Lauf mitgenommen
- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt**
- Der **Fortschrittsbalken** zeigt den Fortschritt für die ausgewählte Dateimenge
(Nenner = Anzahl selektierter Dateien)
- Beide Buttons „Erneut verarbeiten" und „Status zurücksetzen" sind **deaktiviert**
---
## 13b. PDF-Vorschau und editierbarer Dateiname im Verarbeitungslauf-Tab
Nach Abschluss eines Verarbeitungslaufs (oder während laufender Verarbeitung) zeigt
ein Klick auf eine Zeile in der Ergebnisliste ein Detail-Panel auf der rechten Seite.
Das Panel enthält drei Bereiche:
### PDF-Vorschau
- Zeigt die **Quelldatei** der gewählten Zeile als Vorschau an.
- **Lazy Rendering:** Seite 1 wird sofort geladen; weitere Seiten werden erst bei
Bedarf gerendert.
- **In-Memory-Cache:** Bereits gerenderte Seiten werden pro Zeilenselektion
zwischengespeichert. Bei einem Zeilenwechsel wird der Cache der vorherigen Auswahl
verworfen.
- **Seitennavigation:** Über die Schaltflächen **„◀"** und **„▶"** (oder das Mausrad)
kann seitenweise geblättert werden. Die aktuelle Seitenzahl und Gesamtseitenzahl
werden angezeigt.
- **Fit-to-Width:** Nach dem Laden wird die Seite automatisch an die verfügbare Breite
angepasst (preserveRatio=true).
- Das Rendering erfolgt direkt über Apache PDFBox bei 120 DPI.
#### Zoom per Mausrad (Strg+Mausrad)
- **Strg + Mausrad nach oben/unten** zoomt die Vorschau herein bzw. heraus.
- Zoombereich: **10 % bis 500 %**, ca. 10 % je Mausrad-Rastpunkt.
- Nach dem ersten manuellen Zoom verlässt die Vorschau den Fit-to-Width-Modus.
Fit-to-Width wird erst wieder aktiv, wenn ein neues PDF geladen oder der
Fit-to-Width-Button explizit betätigt wird.
- Beim Laden eines neuen PDF wird der Zoom auf Fit-to-Width zurückgesetzt.
- Beim Zoomen bleibt die sichtbare Viewport-Mitte möglichst stabil.
- Trackpad-Gesten (sehr kleine Delta-Werte) werden intern akkumuliert, bis ein
vollständiger Zoomschritt erreicht ist.
- **Ohne Strg:** Mausrad scrollt die Seite normal (kein Zoom).
- ScrollEvents mit gedrückter Strg-Taste werden immer konsumiert, sodass kein
paralleles Scrollen im Hintergrund stattfindet.
#### Grab & Pan (Handcursor im Zoom-Modus)
Im vergrößerten Zustand (Zoom über Fit-to-Width) wechselt der Mauszeiger über
der Vorschau auf einen **Handcursor**. Durch Klicken und Ziehen (Drag) kann die
Ansicht verschoben werden. Im Fit-to-Width-Modus ist Pan nicht aktiv.
### KI-Begründung und Fehlertext
Der mittlere Bereich zeigt das KI-Reasoning des ausgewählten Eintrags.
Für Einträge mit Status `FAILED_*` wird sofern kein KI-Reasoning vorliegt
stattdessen eine übersetzte Fehlermeldung angezeigt (Präfix `⚠`), zum Beispiel:
- „PDF enthält keinen lesbaren Text. Möglicherweise handelt es sich um einen Scan
ohne Texterkennung (OCR). Eine automatische Benennung ist nicht möglich."
- „KI-Dienst: Ungültiger API-Schlüssel. Bitte in den Einstellungen prüfen."
- „KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen."
Für `SKIPPED_ALREADY_PROCESSED`-Einträge erscheint der Zeitpunkt des letzten
erfolgreichen Verarbeitungslaufs, sofern er in der Datenbank vorliegt.
Liegt weder Reasoning noch Fehlermeldung vor, erscheint der Hinweis
„Für diesen Eintrag liegt kein KI-Reasoning vor.".
### Editierbarer Dateiname
Unterhalb des Reasoning-Bereichs befindet sich ein **editierbares Textfeld** mit
dem Dateinamen des ausgewählten Eintrags (ohne `.pdf`-Erweiterung; `.pdf` wird als
nicht editierbares Label daneben angezeigt).
#### Aktivitätszustand je Zeilenstatus
| Zeilenstatus | Textfeld-Verhalten |
|---|---|
| Kein Eintrag selektiert | Leer, deaktiviert |
| `SUCCESS` | Editierbar; letzter gespeicherter Name vorausgefüllt. Ermöglicht Umbenennung der vorhandenen Zieldatei. |
| `SKIPPED_ALREADY_PROCESSED` | Editierbar (sofern historischer Dateiname vorhanden). Ermöglicht Umbenennung der vorhandenen Zieldatei. |
| `FAILED_RETRYABLE`, `FAILED_PERMANENT`, `SKIPPED_FINAL_FAILURE` | Editierbar; Feld leer. Erlaubt Eingabe eines manuellen Dateinamens für eine direkte Kopie der Quelldatei. |
| Zurückgesetzt (`⟳`) | Deaktiviert |
| Lauf aktiv | Vollständig deaktiviert |
Das Feld kann direkt bearbeitet werden. Die Eingabe wird **live validiert**
(Formatprüfung `YYYY-MM-DD - Titel.pdf`, Titelzeichen, Länge).
- Fehlerhafte Eingaben werden direkt unter dem Feld als rote Meldung angezeigt.
- **Speichern:** Der Button **„Übernehmen"** führt die Umbenennung durch atomare
Dateisystem- und DB-Transaktion inkl. automatischer Rollback bei Fehler.
Namenskonflikte im Zielordner werden über ein Dubletten-Suffix aufgelöst.
- **Zurücksetzen:** Der Button **„Zurücksetzen"** verwirft die Änderungen und stellt
den zuletzt persistierten Dateinamen wieder her.
- Wird die Zeile gewechselt oder der Tab verlassen, während ungespeicherte Änderungen
vorliegen, erscheint ein Schutzdialog mit den Optionen **„Speichern"**, **„Verwerfen"**
und **„Abbrechen"**.
- Während eines laufenden Verarbeitungslaufs ist das Dateiname-Feld **gesperrt**.
---
## 13c. Summary-Banner nach Laufabschluss
Nach Abschluss eines Verarbeitungslaufs erscheint unterhalb des Fortschrittsbalkens und
oberhalb der Ergebnistabelle ein einzeiliges **Summary-Banner** (`BatchRunSummaryBanner`).
Es zeigt auf einen Blick, wie viele Dateien in welche Kategorie gefallen sind.
**Beispiel:**
```
✓ 14 erfolgreich · ↻ 1 wird wiederholt · × 2 fehlgeschlagen · ≡ 3 übersprungen · ⊘ 1 endgültig übersprungen
```
**Regeln:**
- Nur Kategorien mit Anzahl größer als 0 werden angezeigt.
- Bei einem vollständig sauberen Lauf erscheint nur die Erfolgskategorie,
z. B. `✓ 17 erfolgreich`.
- Jedes Segment enthält Icon und Text Farbe ist niemals das einzige Unterscheidungsmerkmal.
- Das Banner verschwindet automatisch, wenn der nächste Lauf gestartet wird.
- Das Banner erscheint **nicht** beim Anwendungsstart oder bei einem Tab-Wechsel
ohne vorherigen Lauf.
**Kategorien:**
| Icon | Text | Entsprechender Status |
|------|------|-----------------------|
| `✓` | erfolgreich | `SUCCESS` |
| `↻` | wird wiederholt | `FAILED_RETRYABLE` |
| `×` | fehlgeschlagen | `FAILED_FINAL` |
| `≡` | übersprungen | `SKIPPED_ALREADY_PROCESSED` |
| `⊘` | endgültig übersprungen | `SKIPPED_FINAL_FAILURE` |
Die Zwischenstatus `READY_FOR_AI`, `PROPOSAL_READY` und `PROCESSING` werden im Banner
nicht gezählt sie treten nach Laufabschluss nicht mehr auf.
---
## 14. Tab „Scheduler" (automatische Verarbeitungsläufe)
Der dritte Tab **„Scheduler"** ermöglicht den Betrieb eines optionalen, periodisch
ausgeführten automatischen Schedulers. Er startet Verarbeitungsläufe in einem
konfigurierten Intervall, ohne dass ein manueller Start erforderlich ist.
### Voraussetzung
Damit der Scheduler-Tab funktioniert, muss in der **gespeicherten** Konfigurationsdatei
`scheduler.enabled=true` und ein gültiges `scheduler.interval.seconds` (Integer >= 30)
eingetragen sein. Ungültige oder fehlende Werte werden im Tab als Fehler gemeldet; der
Scheduler-Start ist in diesem Fall nicht möglich.
### Start und Stop
- **„Scheduler starten"** Aktiviert den Scheduler. Der erste Lauf beginnt
**unmittelbar** nach dem Start (kein initiales Warten auf das Intervall).
- **„Scheduler stoppen"** Stoppt den Scheduler. Ein laufender Verarbeitungslauf wird
als Soft-Stop behandelt: die aktuell bearbeitete Datei wird fertig verarbeitet,
danach hält der Scheduler an.
Beide Buttons wechseln je nach Zustand ihre Sichtbarkeit: Nur der zum aktuellen
Zustand passende Button ist aktiv.
### Statusanzeige
Der Tab zeigt den aktuellen Scheduler-Zustand in Echtzeit (1-Sekunden-Takt):
| Zustand | Anzeige |
|---------|---------|
| `STOPPED` | Scheduler gestoppt |
| `STARTING` | Scheduler wird gestartet … |
| `RUNNING_IDLE` | Scheduler läuft nächster Lauf in `HH:MM:SS` |
| `RUNNING_BATCH_ACTIVE` | Scheduler läuft Verarbeitungslauf aktiv |
| `STOPPING_BATCH_ACTIVE` | Scheduler wird gestoppt Lauf läuft noch … |
Im Zustand `RUNNING_IDLE` zeigt der Tab einen Countdown bis zum nächsten automatischen
Verarbeitungslauf.
### Informationen zum letzten Lauf
Der Tab zeigt:
- **Letzter Lauf beendet:** Zeitpunkt des letzten abgeschlossenen Verarbeitungslaufs
(oder „–" wenn noch kein Lauf stattfand).
- **Zusammenfassung:** Anzahl erfolgreich, wiederholt, fehlgeschlagen und übersprungen
des letzten Laufs (falls verfügbar).
- **Letzter Fehler:** Fehlermeldung des letzten nicht erfolgreichen Scheduler-Laufs,
sofern vorhanden.
### Autostart-Fehler
Ist `scheduler.enabled=true` in der Konfiguration, versucht die GUI den Scheduler
beim Start automatisch zu aktivieren. Schlägt dies fehl (z. B. ungültige Konfiguration,
Intervall < 30 Sekunden), wird der Fehler im Tab angezeigt. Der Benutzer kann dann die
Konfiguration korrigieren und den Scheduler manuell starten.
### Warum sind manuelle Läufe während eines aktiven Schedulers gesperrt?
Manuelle Läufe (Tab „Verarbeitungslauf") sind während eines aktiven Schedulers
deaktiviert. Dadurch werden parallele Läufe auf dieselbe Datenmenge vermieden, die
zu inkonsistenten Datenbankzuständen führen könnten. Der Start-Button im Tab
„Verarbeitungslauf" ist während eines aktiven Schedulers deaktiviert und zeigt einen
erklärenden Tooltip.
### Warum ist Tab 1 „Konfiguration" während eines aktiven Schedulers gesperrt?
Um sicherzustellen, dass der Scheduler mit einer konsistenten Konfiguration läuft,
ist der Konfigurations-Editor während eines aktiven Schedulers gesperrt. Ein
Hinweisbanner erklärt die Sperre. Konfigurationsänderungen können nach dem Stoppen
des Schedulers vorgenommen werden.
### Schließen der Anwendung
Versucht der Benutzer das Fenster zu schließen oder die Anwendung über das
Tray-Menü zu beenden, während der Scheduler aktiv ist oder ein Lauf läuft, erscheint
ein Informationsdialog mit dem Hinweis, den Scheduler zunächst zu stoppen bzw. den
laufenden Verarbeitungslauf abzuwarten. Das Schließen wird blockiert, bis der Scheduler
gestoppt und kein Lauf mehr aktiv ist.
---
## 15. Bekannte Einschränkungen
| Einschränkung | Erläuterung |
|---|---|
| Kein manueller Verarbeitungslauf | Das Starten eines Batch-Laufs aus der GUI ist erst ab V2.1+ vorgesehen |
| Kein Historien-Tab | Eine Ansicht der SQLite-Datenbank und Verarbeitungshistorie ist für spätere Ausbaustufen vorbehalten |
| Kein Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten |
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
| Keine Koordination mit parallelen headless Läufen | Läuft gleichzeitig ein headless Batch-Lauf, koordinieren sich GUI und headless Betrieb nicht. Schreibkonflikte können entstehen, wenn dieselbe `.properties`-Datei gleichzeitig über die GUI gespeichert und vom headless Lauf gelesen wird |
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. Die dauerhaften Ergebnisse sind im Verlauf-Tab (Abschnitt 17) einsehbar. |
| Einzelinstanz-Schutz | Wird die Anwendung ein zweites Mal gestartet, während bereits eine Instanz läuft (auch wenn diese im System-Tray minimiert ist), beendet sich die neue Instanz sofort ohne Hinweisfenster |
| Prompt-Editor: kein automatisches Reload | Wird die Prompt-Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht. Beim Speichern gilt Last-write-wins. |
---
## 16. System-Tray
Wird das Hauptfenster über das Schließen-Symbol (oder Alt+F4) geschlossen, ohne dass
ungespeicherte Änderungen oder ein aktiver Verarbeitungslauf vorliegen, **minimiert
sich die Anwendung in den Windows System-Tray** statt sich zu beenden. Das Fenster
bleibt im Hintergrund aktiv und ist über das Tray-Icon wieder erreichbar.
### 15.1 Tray-Icon-Menü
Ein **Rechtsklick** auf das Tray-Icon öffnet ein Kontextmenü:
| Eintrag | Wirkung |
|---------|---------|
| **Öffnen** | Bringt das Hauptfenster in den Vordergrund |
| **Beenden** | Beendet die Anwendung vollständig |
Ein **Doppelklick** auf das Tray-Icon hat denselben Effekt wie „Öffnen".
### 15.2 Sonderfälle beim Schließen
| Situation | Verhalten |
|---|---|
| Ungespeicherte Änderungen | Schutzdialog erscheint zuerst (Speichern / Verwerfen / Abbrechen); erst nach Auflösung wird in den Tray minimiert |
| Aktiver Verarbeitungslauf | Hinweisdialog erscheint (Abschnitt 13); nach Soft-Stop oder Abschluss kann in den Tray minimiert werden |
| System-Tray nicht verfügbar | Fenster wird beim Schließen wie ohne Tray-Support behandelt; der Schutzdialog für ungespeicherte Änderungen bleibt aktiv |
---
## 17. Tab „Verlauf" (Historien-Tab)
Der dritte Tab **„Verlauf"** zeigt alle jemals verarbeiteten Dokumente mit Status,
Dateinamen und Verarbeitungsdetails. Die Daten stammen direkt aus der SQLite-Datenbank,
die in der geladenen Konfiguration angegeben ist.
### Layout
Das Tab ist zweigeteilt:
- **Linke Hälfte (~55%):** Dokumentenliste mit Filter-Bereich oben
- **Rechte Hälfte (~45%):** Detailbereich zum ausgewählten Dokument
### Dokumentenliste
Die Tabelle zeigt folgende Spalten:
| Spalte | Inhalt |
|--------|--------|
| Status-Icon | Symbol und Farbe gemäß Status-Mapping-Tabelle (Abschnitt 20) |
| Quelldateiname | Ursprünglicher Dateiname der PDF-Datei |
| Zieldateiname | Zuletzt vergebener Dateiname nach Umbenennung |
| Quellpfad | Letzter bekannter Quellordner |
| Letzter Versuch | Zeitpunkt der letzten Statusänderung |
| Anzahl Versuche | Gesamtzahl aller Verarbeitungsversuche |
**Sortierung:** Standardmäßig absteigend nach dem letzten Versuch (neueste zuerst).
**Hinweise zur Anzeige:**
- Lange Dateinamen und Pfade werden in der Tabelle abgekürzt (Ellipsis). Der vollständige
Text erscheint im Tooltip beim Hover.
- Bei mehr als 500 Treffern erscheint der Hinweis „Weitere Einträge vorhanden Filter
verwenden". Es werden dann nur die 500 neuesten Einträge angezeigt.
- Bei leerer Datenbank erscheint der Text „Noch keine Verarbeitungen vorhanden."
### Filter
Über dem Tab befinden sich drei Bedienelemente:
- **Freitextsuche** filtert über Quelldateiname und Zieldateiname, case-insensitiv
- **Status-Filter** ComboBox zur Auswahl eines bestimmten Status oder „Alle Status"
- **„Suchen"** startet die Suche sofort; alternativ die Enter-Taste im Suchfeld
Die Suche erfolgt datenbanksseitig; Sonderzeichen in der Sucheingabe werden korrekt behandelt.
#### Live-Suche
Die Freitextsuche reagiert **live** auf Tastatureingaben: 300 ms nach dem letzten
Tastendruck startet die Suche automatisch auf einem Hintergrund-Thread.
Der Such-Button und die Enter-Taste starten die Suche sofort ohne Verzögerung.
Nach jeder neuen Suchanfrage wird die Tabellenauswahl vollständig geleert;
Detailbereich und Aktionsbuttons werden zurückgesetzt. Ein leeres Suchfeld zeigt
alle Einträge (bis Limit 500).
### Mehrfachauswahl
Die Verlauf-Tabelle unterstützt **Mehrfachauswahl**:
| Geste | Wirkung |
|---|---|
| **Klick** | Einzelauswahl |
| **Strg+Klick** | Einzelnen Eintrag zur Auswahl hinzufügen oder entfernen |
| **Shift+Klick** | Bereich vom letzten zur aktuellen Zeile auswählen |
| **Strg+A** | Alle sichtbaren Einträge auswählen (**nur wenn die Tabelle den Fokus hat**) |
> **Hinweis:** Liegt der Fokus im Suchfeld, wirkt Strg+A als normale Textselektion
> im Suchfeld und selektiert keine Tabellenzeilen.
Bei Mehrfachauswahl zeigt der **Detailbereich** den Platzhaltertext
„X Einträge ausgewählt." (statt Dokumentdetails).
### Detailbereich
Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
**Dokument-Info:**
- Fingerprint (12 Zeichen des SHA-256-Hash)
- Quelldateiname und Quellpfad
- Status (Icon + Text)
- Erstellt am / Aktualisiert am
**Versuche-Tabelle:** Alle bisher protokollierten Verarbeitungsversuche:
| Spalte | Inhalt |
|--------|--------|
| # | Versuchsnummer |
| Datum | Endzeitpunkt des Versuchs |
| Status | Ergebnisstatus des Versuchs (lesbarer Anzeigetext, kein Enum-Rohname) |
| Provider | Verwendeter KI-Provider |
| Modell | Verwendetes Sprachmodell |
| Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname |
**KI-Begründung / Fehlerursache:**
Das `ai_reasoning` des zuletzt ausgewählten Versuchs als nicht editierbarer Text.
Ist kein Reasoning gespeichert, erscheint ein gedimmter Platzhaltertext
„Keine KI-Begründung für diesen Versuch gespeichert."
Bei Einträgen mit Status `FAILED_FINAL`, `FAILED_RETRYABLE` oder
`SKIPPED_FINAL_FAILURE` wird zusätzlich die **Fehlerursache** des letzten
fehlgeschlagenen Versuchs angezeigt. Liegt keine Fehlerursache vor (z. B. ältere
Einträge), erscheint ebenfalls ein Platzhaltertext.
### Aktionen
Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung.
**Beide Aktionen unterstützen Mehrfachauswahl** (≥ 1 Eintrag):
**„Status zurücksetzen"**
Setzt den Status der ausgewählten Dokumente auf „Wartet auf Verarbeitung" zurück,
sodass sie beim nächsten Verarbeitungslauf automatisch erneut verarbeitet werden.
Die Versuchshistorie bleibt vollständig erhalten kein Versuch wird gelöscht.
Vor der Aktion erscheint ein Bestätigungsdialog: „X Einträge zurücksetzen?"
Bei Mehrfachauswahl werden Einträge einzeln zurückgesetzt. Nach Abschluss erscheint
eine kompakte Zusammenfassung „X von Y erfolgreich verarbeitet." Detaillierte
Einzelfehler werden geloggt.
Wann sinnvoll: wenn die Ursache eines Fehlers behoben wurde (z. B. OCR nachträglich
durchgeführt, Passwortschutz entfernt) und das Dokument erneut verarbeitet werden soll.
**„Eintrag löschen"**
Löscht die Stammsätze und alle Verarbeitungsversuche der ausgewählten Dokumente
vollständig aus der Datenbank. Diese Aktion ist **nicht rückgängig zu machen**.
Vor der Aktion erscheint ein Bestätigungsdialog: „X Einträge unwiderruflich löschen?"
Bei Mehrfachauswahl gilt dieselbe Partial-Success-Logik wie beim Zurücksetzen.
**Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert.
Nach Laufende werden die Buttons automatisch reaktiviert, sofern eine Auswahl besteht
ohne dass der Benutzer die Auswahl erneuern muss.
---
## 17a. Neue Datenbank anlegen
Über **Datenbank → Neue Datenbank anlegen...** in der Menüleiste kann eine neue,
leere SQLite-Datenbank erstellt und sofort als aktive Datenbank der Anwendung
gesetzt werden ohne Neustart.
### Voraussetzung
Der Menüpunkt ist nur aktiv, wenn kein Verarbeitungslauf läuft.
### Ablauf
1. Ein Dateidialog öffnet sich (Filter: `*.sqlite` und `*.db`). Neue Zieldatei
wählen oder eingeben.
2. Die Anwendung prüft, ob die gewählte Datei identisch mit der aktuell aktiven
Datenbank ist (normalisierter, case-insensitiver Pfadvergleich). Bei
Übereinstimmung erscheint eine Fehlermeldung, kein Überschreiben.
3. Existiert die gewählte Datei bereits (andere als aktive DB): Bestätigungsdialog
„Die Datei existiert bereits. Überschreiben?"
4. Die neue DB wird als temporäre Datei im Zielverzeichnis erzeugt. Flyway
führt alle Migrationsskripte auf den neuesten Schema-Stand aus.
5. Verbindungstest: Verbindung öffnen, Flyway-History prüfen, Leseabfrage prüfen.
6. Nach erfolgreichem Test: atomarer Move zur Zieldatei
(`ATOMIC_MOVE + REPLACE_EXISTING`). Schlägt dies fehl, bricht der Vorgang
mit einer klaren Fehlermeldung ab.
7. Die aktive Datenbankverbindung wechselt zur neuen DB.
8. Der Verlauf-Tab lädt neu: „Noch keine Verarbeitungen vorhanden."
9. Die Statuszeile aktualisiert den DB-Pfad.
10. Die Konfiguration wird als geändert markiert (Dirty-State im Konfig-Tab).
11. Im Meldungsbereich erscheint der Hinweis:
„Neue Datenbank ist aktiv. Konfiguration speichern, damit diese Datenbank
beim nächsten Start verwendet wird."
### Fehlerfall
Schlägt ein Schritt fehl, bleibt die bisherige Datenbank vollständig unverändert
in Betrieb. Die temporäre Datei wird gelöscht. Ein Fehlerdialog erscheint mit
einer konkreten Meldung.
### Wichtiger Hinweis
**Die Konfigurationsdatei wird durch den DB-Wechsel nicht automatisch gespeichert.**
Damit die neue Datenbank beim nächsten Start der Anwendung verwendet wird, muss
die Konfiguration explizit über „Speichern" oder „Speichern unter" gesichert werden.
Der Dirty-State im Konfig-Tab und der Hinweis im Meldungsbereich erinnern daran.
---
## 18. Tab „Prompt" (Prompt-Editor)
Der vierte Tab **„Prompt"** ermöglicht das Lesen, Bearbeiten und Speichern der
KI-Prompt-Datei direkt in der GUI ohne externen Editor.
### Inhalt und Bedienung
Die TextArea zeigt den aktuellen Inhalt der in der Konfiguration eingetragenen
Prompt-Datei. Der Inhalt ist vollständig editierbar.
**Buttons:**
- **„Speichern"** schreibt den aktuellen Inhalt atomar in die Prompt-Datei
(Temp-Datei im selben Verzeichnis, dann atomarer Austausch). Encoding: UTF-8;
Zeilenenden werden unverändert übernommen. Bei einem Fehler erscheint eine
Fehlermeldung im Tab; es gibt keinen stillen Fallback.
- **„Auf Standard zurücksetzen"** füllt die TextArea mit dem eingebauten
Standard-Template, ohne die Datei sofort zu speichern. Erst ein anschließendes
„Speichern" schreibt die Änderung auf den Datenträger.
**Dirty State:**
Sobald der TextArea-Inhalt vom gespeicherten Stand abweicht, erscheint ein
Asterisk im Tab-Titel: **„Prompt \*"**. Wird der Tab gewechselt oder die
Anwendung geschlossen, während ungespeicherte Änderungen vorliegen, erscheint
ein Bestätigungsdialog mit der Frage „Änderungen verwerfen?".
### Fehlende Prompt-Datei
Ist keine Prompt-Datei konfiguriert oder existiert die konfigurierte Datei nicht,
zeigt der Tab einen Hinweistext und den Button **„Standard-Prompt erstellen"**.
Ein Klick legt eine Prompt-Datei mit dem deutschen Standard-Template an
(standardmäßig im selben Ordner wie die geladene `.properties`-Datei).
### Hinweise
- Das Tab lädt den Dateiinhalt automatisch, wenn es geöffnet wird (Hintergrund-Thread).
- Wird die Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht.
Beim Speichern gilt Last-write-wins.
- Für den Betrieb über MSI oder Task Scheduler wird empfohlen, den Prompt-Pfad
in der Konfiguration als absoluten Pfad anzugeben, um vom jeweiligen Arbeitsverzeichnis
unabhängig zu sein.
---
## 19. Statuszeile
Am unteren Rand des Hauptfensters ist permanent eine **Statuszeile** (`GuiStatusBar`)
sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
| Position | Inhalt | Beispiel |
|----------|--------|---------|
| Links | Anwendungsversion | `V3.0.42` |
| Mitte | Aktiver Provider und Modell | `Provider: Claude · claude-opus-4-7` |
| Rechts | Pfad der geladenen Konfigurationsdatei | `config/application.properties` |
**Besonderheiten:**
- Die Versionsangabe wird aus der JAR-Manifest-Datei gelesen. Beim Start aus einer IDE
ohne gepacktes JAR erscheint der Fallback `Vdev`.
- Ist keine Konfiguration geladen, zeigen Mitte und Rechts den Text „Kein Profil geladen".
- Die Statuszeile aktualisiert sich automatisch beim Laden, Speichern und Schließen
einer Konfigurationsdatei.
---
## 20. Fehlerstatus Bedeutung und Unterscheidung
Zwei Fehlerstatus werden in der GUI klar unterschieden. Die Unterscheidung ist wichtig,
um zu entscheiden, ob eine erneute Verarbeitung sinnvoll ist.
### `↻` Wird wiederholt (orange) `FAILED_RETRYABLE`
Das Dokument konnte vorübergehend nicht verarbeitet werden. Der Fehler ist
wahrscheinlich technischer Natur und kann sich bei einem späteren Versuch
von selbst auflösen.
**Typische Ursachen:** Netzwerkfehler, Timeout beim KI-Dienst, vorübergehende
Nicht-Erreichbarkeit.
**Was passiert:** Das Dokument wird beim nächsten regulären Verarbeitungslauf
**automatisch erneut versucht** kein manuelles Eingreifen notwendig.
### `×` Fehlgeschlagen (rot) `FAILED_FINAL`
Das Dokument ist dauerhaft nicht verarbeitbar. Automatische Wiederholversuche
werden nicht mehr unternommen.
**Typische Ursachen:**
- Kein lesbarer Text (z. B. eingescanntes Foto ohne OCR-Verarbeitung)
- Passwortgeschützte PDF
- Beschädigte oder unlesbare Datei
**Was passiert:** Das Dokument wird in späteren Läufen übersprungen.
**Mögliche Abhilfe:** Wenn die Ursache behoben wurde (z. B. OCR wurde nachträglich
durchgeführt), kann der Status im **Verlauf-Tab** (Abschnitt 17) manuell zurückgesetzt
werden. Das Dokument wird dann beim nächsten Lauf erneut verarbeitet. Alternativ kann
der Eintrag vollständig gelöscht werden, damit die Datei als neu erkannt wird.
---
### Vollständige Status-Mapping-Tabelle
| Status | Icon | Farbe | Tooltip-Text | Summary-Kategorie |
|--------|------|-------|-------------|-------------------|
| `SUCCESS` | `✓` | Grün | „Erfolgreich verarbeitet und umbenannt." | erfolgreich |
| `FAILED_RETRYABLE` | `↻` | Orange | „Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht." | wird wiederholt |
| `FAILED_FINAL` | `×` | Rot | „Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch." | fehlgeschlagen |
| `SKIPPED_ALREADY_PROCESSED` | `≡` | Grau | „Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet." | übersprungen |
| `SKIPPED_FINAL_FAILURE` | `⊘` | Dunkelgrau | „Endgültig übersprungen nach wiederholten Fehlern." | endgültig übersprungen |
| `READY_FOR_AI` | `⟳` | Blau | „Wartet auf Verarbeitung." | |
| `PROPOSAL_READY` | `◇` | Hellblau | „KI-Vorschlag liegt vor, wartet auf Bestätigung." | |
| `PROCESSING` | `▶` | Hellgrau | „Wird gerade verarbeitet." | |
**Wichtig:** Farbe ist niemals das einzige Unterscheidungsmerkmal. Icon und Tooltip-Text
beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
---
## 21. Tooltips
Auf den meisten interaktiven Elementen der GUI sind Tooltips gesetzt, die beim Hover über
ein Element erscheinen. Sie erklären kurz den Zweck des Elements.
Tooltips sind unter anderem vorhanden auf:
- **Konfigurationsfeldern** Quellordner, Zielordner, SQLite-Datei, Prompt-Datei,
Provider-ComboBox, Modell-Feld, `max.text.characters`, `max.pages`, `max.title.length`
- **Toolbar-Buttons** Neu, Öffnen, Speichern, Speichern unter, Validieren,
Technische Tests ausführen
- **Status-Icons** im Verarbeitungslauf-Tab Text gemäß Status-Mapping-Tabelle
(Abschnitt 20)
- **Buttons „Dateiname übernehmen"** und **„Zurücksetzen auf KI-Vorschlag"** im
Dateiname-Editor (Abschnitt 13b)
Der Tooltip erscheint nach einer kurzen Verzögerung beim Verweilen mit der Maus
über dem jeweiligen Element.
+120
View File
@@ -0,0 +1,120 @@
# V2.6 Titellänge parametrisierbar machen
**Status:** Entwurf
**Erstellt:** 2026-04-22
**Autor:** Marcus (mit Claude als Mentor)
---
## Ziel
Der maximale Basistitel für KI-generierte PDF-Namen wird nicht mehr hardcodiert,
sondern ist über die Konfigurationsdatei steuerbar. Alle bisherigen Magic Numbers
(20 und 60 Zeichen) werden durch den konfigurierten Wert ersetzt.
---
## Hintergrund
### Bisheriger Zustand
- Titellänge war mit 20 Zeichen im Prompt und 60 Zeichen in der Validierung hardcodiert
- Kein zentraler Konfigurationsparameter, Werte über ~20 Dateien verstreut
- 60-Zeichen-Limit wurde im Rahmen des Produkttests als pragmatischer Zwischenwert eingeführt
### Motivation
- Verschiedene Einsatzszenarien erfordern unterschiedliche Titellängen
- Dateinamenlimits je nach Zielsystem unterschiedlich (siehe Recherche unten)
### Recherchierte Dateinamenlimits (nur Dateiname, ohne Pfad)
| System | Limit |
|---|---|
| Windows 10 / Windows Server 2022 (NTFS) | 255 Zeichen |
| Synology NAS Btrfs (unverschlüsselt) | 255 Zeichen |
| Synology NAS Btrfs (verschlüsselt) | ~143 Zeichen |
**Hinweis:** Der generierte Dateiname hat das Format `YYYY-MM-DD - <Titel>.pdf`,
was bereits 18 Zeichen Overhead bedeutet (Datum + Trennzeichen + Dateiendung).
Das sicherste Maximum für verschlüsselte Synology-Volumes ist daher **120 Zeichen**
für den Basistitel (143 18 = 125, mit Puffer auf 120 gerundet).
---
## Fachliche Anforderungen
### Neuer Konfigurationsparameter
- **Name:** `ai.title.max.length` (finale Benennung obliegt der Implementierung)
- **Typ:** positive Ganzzahl
- **Defaultwert:** `60` (bisheriger Wert bleibt erhalten, kein Breaking Change)
- **Speicherort:** `.properties`-Konfigurationsdatei
---
### Validierungsregeln
| Wert | Typ | Verhalten |
|---|---|---|
| Kein Wert / leer | Fehler | Pflichtfeld, Start wird abgebrochen |
| Keine Ganzzahl (z. B. „abc", „1.5") | Fehler | Ungültiger Typ, Start wird abgebrochen |
| < 1 | Fehler | Wert muss positiv sein, Start wird abgebrochen |
| 19 | Fehler | Minimum ist 10 Zeichen, Start wird abgebrochen |
| 1039 | Warnung | „Titellänge unter 40 Zeichen KI-Ergebnisse können unvollständig sein, da Absender allein bereits 1520 Zeichen benötigt" |
| 4099 | OK | Normaler Betrieb, keine Meldung |
| 100120 | Warnung | „Hohe Titellänge Kompatibilität mit verschlüsselten Volumes prüfen" |
| > 120 | Fehler | Überschreitet sicheres Limit für verschlüsselte Synology-Volumes, Start wird abgebrochen |
---
### GUI Konfigurationseditor
- Neues Texteingabefeld im Bereich **„Verarbeitungslimits"**
- Beschriftung: **„Max. Titellänge (Zeichen)"**
- Validierung erfolgt beim Speichern ungültige Werte werden **nicht** gespeichert
- Warnungen und Fehlermeldungen erscheinen im **Meldungsbereich** (unten in der GUI)
- Warnungen blockieren das Speichern **nicht**, Fehler hingegen schon
---
### Verarbeitung / Backend
- Alle hardcodierten `20`- und `60`-Zeichen-Limits werden durch den konfigurierten Wert ersetzt
- **Keine Magic Numbers** mehr im Produktionscode
- Der Wert wird beim Start geladen, validiert und an alle betroffenen Komponenten weitergereicht
- Betroffen sind mindestens:
- `AiResponseValidator`
- `TargetFilenameBuildingService`
- Prompt-Template (Hinweistext an die KI)
- JavaDoc aller betroffenen Klassen
---
### Prompt-Template
- Der Hinweis auf die Zeichenbegrenzung im Prompt-Template (`config/prompts/template.txt`)
wird ebenfalls dynamisch mit dem konfigurierten Wert befüllt
- **Hinweis:** Das Prompt-Template liegt außerhalb des JARs und wird zur Laufzeit gelesen.
Die Implementierung muss sicherstellen, dass der konfigurierte Wert zur Laufzeit
in den Prompt eingesetzt wird (z. B. per Platzhalter-Ersetzung).
---
## Nicht in V2.6 enthalten
- Automatisches Kürzen von zu langen KI-Titeln
- Pfadlängen-Validierung (Gesamtpfad inkl. Ordner)
- Unterschiedliche Limits je nach Zielsystem (nur ein globaler Wert)
---
## Abnahmekriterien
- [ ] Neuer Parameter ist in der `.properties`-Datei konfigurierbar
- [ ] Defaultwert 60 ist abwärtskompatibel (bestehende Configs ohne den Parameter funktionieren)
- [ ] Alle Validierungsregeln greifen korrekt (Fehler blockieren Start/Speichern, Warnungen nicht)
- [ ] GUI zeigt das neue Feld im richtigen Bereich
- [ ] Meldungsbereich zeigt passende Warn- und Fehlertexte
- [ ] Keine hardcodierten 20- oder 60-Zeichen-Limits mehr im Produktionscode
- [ ] Prompt-Template enthält den konfigurierten Wert zur Laufzeit
- [ ] Alle bestehenden Tests werden angepasst
- [ ] `mvn clean verify` ist grün
+297
View File
@@ -0,0 +1,297 @@
# V2.7 GUI-Verarbeitungslauf mit Live-Verfolgung
**Status:** Freigegeben
**Erstellt:** 2026-04-22
**Überarbeitet:** 2026-04-22 (nach Review, finale Version)
**Autor:** Marcus (mit Claude als Mentor)
---
## Ziel
V2.7 erweitert die JavaFX-GUI um einen zweiten Tab „Verarbeitungslauf", über den der Benutzer
einen Batch-Lauf direkt aus der GUI starten und dessen Fortschritt in Echtzeit verfolgen kann.
Der bestehende headless-Betrieb über den Windows Task Scheduler bleibt unverändert erhalten.
---
## Hintergrund
### Bisheriger Zustand
- Die GUI dient in V2.0V2.6 ausschließlich der Konfiguration und technischen Validierung
- Ein Verarbeitungslauf kann nur über die Kommandozeile bzw. eine Batch-Datei gestartet werden
- Es gibt keine Möglichkeit, den Fortschritt eines laufenden Batches live zu beobachten
### Motivation
- Der manuelle Kommandozeilenstart ist für den Alltagsbetrieb umständlich
- Ohne Live-Anzeige ist unklar, ob und wie schnell die Verarbeitung voranschreitet
- Eine einzelne Datei wird schnell verarbeitet eine Gesamtfortschrittsanzeige ist daher
sinnvoller als eine dateiweise Einzelanzeige
---
## Zielbild
Nach Abschluss von V2.7 kann der Benutzer:
1. Im neuen Tab „Verarbeitungslauf" einen Batch-Lauf starten
2. Den Gesamtfortschritt über alle Dateien live verfolgen
3. Jede abgeschlossene Datei mit Ergebnis in einer Liste sehen
4. Das KI-Reasoning zu einer Datei per Klick im Seitenbereich einsehen
5. Den laufenden Batch per Soft-Stop sauber abbrechen
---
## Fachliche Anforderungen
### Neuer Tab „Verarbeitungslauf"
- Der bestehende Tab „Konfiguration" bleibt Tab 1 unverändert
- Tab 2 heißt **„Verarbeitungslauf"**
- Tab-Struktur war in V2.0 bereits vorbereitet
---
### Layout Tab 2
```
┌─────────────────────────────────────────────────────────┐
│ [Fortschrittsbalken] 12 / 47 Dateien │
├──────────────────────────────────┬──────────────────────┤
│ Ergebnisliste │ Seitenbereich │
│ (scrollbar) │ (KI-Reasoning) │
│ │ │
│ │ │
├──────────────────────────────────┴──────────────────────┤
│ Meldungs- und Zusammenfassungsbereich │
├─────────────────────────────────────────────────────────┤
│ [Starten] [Abbrechen] │
└─────────────────────────────────────────────────────────┘
```
---
### Meldungs- und Zusammenfassungsbereich
Der untere Bereich des Tab 2 dient als **einheitlicher Meldungs- und Zusammenfassungsbereich**.
Er übernimmt zwei Rollen:
- **Meldungsbereich** zeigt Startfehler, Hinweise (z. B. 0 Dateien) und technische Exceptions
- **Zusammenfassung** zeigt nach Laufende: `{X} erfolgreich, {X} fehlgeschlagen, {X} übersprungen`
Während des Laufs ist der Bereich leer oder zeigt den letzten Statushinweis.
Es gibt in Tab 2 keinen separaten zweiten Meldungsbereich.
---
### Konfigurationsquelle beim Start
- Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der `.properties`-Datei
- Ungespeicherte Änderungen im Konfigurationseditor (Tab 1) fließen **nicht** in den Lauf ein
- Der Starten-Button prüft vor dem Lauf, ob die gespeicherte Konfiguration lauffähig ist
nicht den aktuellen Editorzustand
---
### Startvoraussetzungen und Startfehler
Ein Lauf startet nur, wenn alle folgenden Voraussetzungen erfüllt sind:
| Voraussetzung | Verhalten bei Fehler |
|---|---|
| Gespeicherte Konfiguration vorhanden und lauffähig | Fehlermeldung, kein Lauf |
| Quellordner vorhanden und lesbar | Fehlermeldung, kein Lauf |
| Zielordner vorhanden oder anlegbar | Fehlermeldung, kein Lauf |
| SQLite-Datei nutzbar | Fehlermeldung, kein Lauf |
| API-Key vorhanden | Fehlermeldung, kein Lauf |
| Kein anderer Verarbeitungslauf in dieser Anwendungsinstanz aktiv | Fehlermeldung, kein Lauf |
Bei einem Startfehler:
- Erscheint eine klare Fehlermeldung im Meldungs- und Zusammenfassungsbereich
- Fortschrittsbalken und Ergebnisliste bleiben unverändert
- Starten-Button bleibt aktiv, Abbrechen-Button bleibt deaktiviert
---
### Verhalten bei 0 verarbeitbaren Dateien
- Kein technischer Fehler
- Kein Lauf im eigentlichen Sinne
- Hinweis im Meldungs- und Zusammenfassungsbereich: „Keine verarbeitbaren Dateien im Quellordner gefunden"
- Zusammenfassung: `0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen`
---
### Fortschrittsbalken
- Die zu verarbeitende Dateimenge wird **einmalig beim Start** bestimmt
- Der Nenner bleibt für den gesamten Lauf **konstant** Dateien die während des Laufs
im Quellordner auftauchen oder verschwinden, werden nicht berücksichtigt
- Gezählt werden **alle abgeschlossenen** Dateien: erfolgreich + fehlgeschlagen + übersprungen
- Daneben wird der Zählerstand angezeigt, z. B. „12 / 47 Dateien"
- Vor dem ersten Start: leer / 0 %
---
### Statusmodell
Jede Datei erhält nach Abschluss genau einen der folgenden Status:
| Status | Icon | Bedeutung |
|---|---|---|
| Erfolgreich | ✅ | Datei wurde umbenannt, Zieldatei erzeugt |
| Fehlgeschlagen (retryable) | ⚠️ | Transienter Fehler, wird beim nächsten Lauf erneut versucht |
| Fehlgeschlagen (permanent) | ❌ | Inhaltsfehler, kein weiterer Retry |
| Übersprungen | ⏭️ | Datei war bereits verarbeitet oder wurde bewusst ausgelassen |
Alle vier Status zählen als **abgeschlossen** im Sinne des Fortschrittsbalkens.
---
### Ergebnisliste
Jede abgeschlossene Datei erscheint als neue Zeile in der Liste.
Nach Abschluss jeder Datei erscheint **ohne manuellen Refresh** ein neuer Eintrag.
Die Liste wächst während des Laufs von oben nach unten.
| Spalte | Erfolg | Fehler / Übersprungen |
|---|---|---|
| Status-Icon | ✅ / ⚠️ / ❌ / ⏭️ | wie links |
| Originaldateiname | Quelldateiname | Quelldateiname |
| Neuer Dateiname | Finaler Zieldateiname | `—` |
| Datum | Ermitteltes Datum | `—` |
| Dauer | Verarbeitungszeit in Sekunden | Verarbeitungszeit in Sekunden |
- Klick auf eine Zeile zeigt Details im **Seitenbereich**
- Die Liste ist scrollbar
- Die Liste ist **nicht persistent**: bleibt nur für die Dauer des aktuellen Programmstarts
- Bei einem neuen Lauf innerhalb desselben Programmstarts wird die Liste geleert
- Nach Programmstart ist die Liste leer
---
### Seitenbereich (KI-Reasoning)
- Rechts neben der Ergebnisliste, fest im Layout verankert (kein Popup, kein Dialog)
- Zeigt nach Klick auf eine Zeile:
- Originaldateiname
- Ermittelter Titel
- Ermitteltes Datum
- KI-Reasoning (Volltext)
- Liegt für einen Eintrag kein KI-Reasoning vor (Fehler vor KI-Aufruf, übersprungen),
erscheint der Hinweistext: „Für diesen Eintrag liegt kein KI-Reasoning vor."
- Vor dem ersten Klick: Hinweistext „Datei auswählen für Details"
- Bei neuem Lauf wird der Seitenbereich geleert
---
### Starten-Button
- Startet den Verarbeitungslauf über alle Dateien im konfigurierten Quellordner
- Verwendet die **gespeicherte** Konfiguration nicht den aktuellen Editorzustand
- Gleiches fachliches Batch-Verhalten wie der headless-Betrieb:
gleiche Anwendungslogik, gleicher Use Case, nur andere Präsentationsschicht
- Keine Dateiauswahl alle Dateien werden verarbeitet
- Während des Laufs: deaktiviert
- Nach Abschluss oder Abbruch: wieder aktiv
---
### Abbrechen-Button
- Nur während eines laufenden Batches aktiv, sonst deaktiviert
- Verhalten: **Soft-Stop**
- Die aktuell in Bearbeitung befindliche Datei wird vollständig fertig verarbeitet
- Das Stop-Flag wird nach Abschluss jeder Datei und vor Start der nächsten Datei geprüft
niemals mitten in einer atomaren Persistenzoperation
- Danach wird der Lauf sauber beendet, keine halbfertigen Zustände in der SQLite-Datenbank
- Nach dem Soft-Stop erscheint die Zusammenfassung im Meldungs- und Zusammenfassungsbereich
---
### Konfiguration während des Laufs
- Tab 1 „Konfiguration" wird während eines laufenden Verarbeitungslaufs **gesperrt**
- Im Konfiguration-Tab erscheint ein sichtbarer Hinweis:
„Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar"
- Nach Abschluss, Abbruch oder unerwarteter Exception wird Tab 1 wieder freigegeben
---
### Verhalten bei unerwarteter technischer Exception
Tritt während des Laufs eine unerwartete Exception auf:
- Die GUI wechselt in einen definierten terminalen Zustand:
- Starten-Button: aktiv
- Abbrechen-Button: deaktiviert
- Tab 1: entsperrt
- Meldungs- und Zusammenfassungsbereich: Fehlermeldung sichtbar
- Es entsteht kein „hängender" UI-Zustand
---
### Fenster schließen während eines laufenden Laufs
- Schließt der Benutzer das Fenster während ein Lauf aktiv ist,
wird der Close-Request abgefangen
- Es erscheint ein Hinweisdialog mit zwei Optionen:
- **„Nicht schließen"** Lauf läuft weiter
- **„Lauf beenden und schließen"** Soft-Stop wird ausgelöst,
nach Abschluss der aktuellen Datei schließt die Anwendung
- Kein Hard-Abbruch ohne Benutzerentscheidung
---
### Parallele Läufe
- Pro Anwendungsinstanz ist **nur ein Verarbeitungslauf gleichzeitig** zulässig
- Ein zweiter Startversuch während ein Lauf aktiv ist wird verweigert mit der Meldung:
„Ein Verarbeitungslauf ist bereits aktiv."
- **Bekannte Einschränkung:** Ein gleichzeitiger externer headless-Lauf (Windows Task Scheduler)
wird von der GUI nicht aktiv erkannt und nicht technisch geblockt.
Der Benutzer ist selbst verantwortlich, parallele Läufe zu vermeiden.
Diese Einschränkung ist seit V2.0 dokumentiert und bleibt in V2.7 unverändert bestehen.
---
## Nicht in V2.7 enthalten
- Dateiauswahl (welche Dateien verarbeitet werden sollen)
- Einzeldatei-Fortschrittsanzeige
- Historien-Tab / SQLite-Ansicht
- Kosten-Tracking
- Automatischer Neustart nach Abschluss
- Benachrichtigungen (Windows-Tray, Toast)
- Parallelverarbeitung mehrerer Dateien
- Technisches Locking gegen externe headless-Läufe
---
## Abnahmekriterien
- [ ] Tab 2 „Verarbeitungslauf" ist in der GUI vorhanden und erreichbar
- [ ] Starten-Button verwendet ausschließlich die gespeicherte Konfiguration
- [ ] Starten-Button startet den Batch-Lauf über alle Dateien im Quellordner
- [ ] Die Dateimenge wird beim Start einmalig bestimmt; der Nenner des Fortschrittsbalkens bleibt während des gesamten Laufs konstant
- [ ] Fortschrittsbalken zählt alle abgeschlossenen Dateien (erfolgreich + fehlgeschlagen + übersprungen)
- [ ] Nach Abschluss jeder Datei erscheint ohne manuellen Refresh ein neuer Eintrag in der Ergebnisliste
- [ ] Alle fünf Spalten der Ergebnisliste sind für Erfolgsfälle korrekt befüllt
- [ ] Spalte „Neuer Dateiname" und „Datum" zeigen `—` für Fehler- und Übersprungen-Fälle
- [ ] Alle vier Status-Icons sind korrekt: ✅ ⚠️ ❌ ⏭️
- [ ] Klick auf Zeile zeigt KI-Reasoning im Seitenbereich
- [ ] Einträge ohne KI-Reasoning zeigen den definierten Hinweistext im Seitenbereich
- [ ] Seitenbereich zeigt vor erstem Klick den Hinweistext „Datei auswählen für Details"
- [ ] Soft-Stop beendet den Lauf nach Abschluss der aktuellen Datei; keine weitere Datei wird begonnen
- [ ] Meldungs- und Zusammenfassungsbereich zeigt nach Laufende die Zusammenfassung mit korrekten Zählern
- [ ] Tab 1 ist während des Laufs gesperrt, Hinweis ist sichtbar
- [ ] Tab 1 wird nach Abschluss, Abbruch oder Exception wieder entsperrt
- [ ] Bei unerwarteter Exception wechselt die GUI in den definierten terminalen Zustand
- [ ] Ergebnisliste und Seitenbereich sind nach Programmstart leer
- [ ] Ergebnisliste und Seitenbereich werden bei neuem Lauf geleert
- [ ] Start mit nicht lauffähiger Konfiguration wird verweigert; Fehlermeldung erscheint im Meldungs- und Zusammenfassungsbereich
- [ ] Start bei leerem Quellordner erzeugt keinen Fehler; Hinweis erscheint im Meldungs- und Zusammenfassungsbereich
- [ ] Zweiter Startversuch während laufendem Lauf wird verweigert; Meldung erscheint
- [ ] Close-Request während Lauf öffnet Hinweisdialog mit zwei Optionen
- [ ] headless-Betrieb ist unverändert funktionsfähig
- [ ] `mvn clean verify` ist grün
+193
View File
@@ -0,0 +1,193 @@
# V2.8 Selektive Wiederverarbeitung und Status-Reset in der Ergebnisliste
**Status:** Freigegeben
**Erstellt:** 2026-04-23
**Überarbeitet:** 2026-04-23 (nach zwei Reviews, finale Version)
**Autor:** Marcus (mit Claude als Mentor)
---
## Ziel
V2.8 erweitert den Tab „Verarbeitungslauf" um die Möglichkeit, einzelne oder mehrere Dateien
aus der Ergebnisliste gezielt erneut verarbeiten zu lassen oder deren DB-Status zurückzusetzen
ohne die gesamte Datenbank löschen zu müssen.
---
## Hintergrund
### Bisheriger Zustand
- Nach einem abgeschlossenen Lauf sind alle Ergebnisse in der Ergebnisliste sichtbar
- Dateien mit Status `FAILED_FINAL` oder `DONE` können nur durch manuelles Löschen der
SQLite-Datenbank erneut verarbeitet werden
- Es gibt keine Möglichkeit, einzelne Dateien selektiv zurückzusetzen oder neu zu starten
### Motivation
- Nach Anpassung des Prompts oder Wechsel des KI-Modells sollen bereits verarbeitete Dateien
erneut verarbeitet werden können ohne Datenverlust für andere Dokumente
- Permanent fehlgeschlagene Dateien sollen nach Behebung der Ursache gezielt neu gestartet
werden können
- Zwei klar getrennte Aktionen decken unterschiedliche Anwendungsfälle ab:
sofortige Wiederverarbeitung vs. Reset für den nächsten regulären Lauf
---
## Zielbild
Nach Abschluss von V2.8 kann der Benutzer:
1. Eine oder mehrere Dateien in der Ergebnisliste selektieren
2. Per „Erneut verarbeiten" einen sofortigen Mini-Lauf nur für die selektierten Dateien starten
3. Per „Status zurücksetzen" den DB-Status zurücksetzen ohne sofortige Verarbeitung
die Dateien werden beim nächsten regulären Lauf automatisch mitgenommen
---
## Fachliche Anforderungen
### Selektion in der Ergebnisliste
- Es gibt genau **eine fachliche Selektion** je Ergebniszeile
- Checkbox, Zeilenklick, Shift/Strg und „Alle auswählen" wirken immer auf **dieselbe Selektionsmenge**
- Jede Zeile erhält eine **Checkbox** am linken Rand
- **Shift/Strg-Mehrfachselektion** wie im Windows Explorer ist möglich
- Eine Checkbox **„Alle auswählen"** oberhalb der Liste selektiert/deselektiert alle Einträge
- Alle Status sind selektierbar: ✔ erfolgreich, ⚠ retryable, ✘ permanent, ► übersprungen
- Die Selektion bleibt erhalten bis ein neuer Lauf gestartet wird
- Während eines laufenden Mini-Laufs ist die Selektion **gesperrt**
Änderungen der Selektion nach Laufstart haben keinen Einfluss auf den laufenden Batch
---
### Button „Erneut verarbeiten"
- **Aktion:** DB-Status der selektierten Dateien zurücksetzen + sofortiger Mini-Lauf
nur für diese Dateien
- **Aktiv wenn:** Kein Lauf aktiv UND mindestens 1 Eintrag selektiert
- **Inaktiv wenn:** Lauf läuft ODER keine Selektion
- **Verhalten:**
- Der Mini-Lauf arbeitet auf einem **Snapshot** der beim Klick selektierten Einträge
- DB-Status aller selektierten Einträge wird zurückgesetzt
- Sofort danach startet ein Mini-Lauf ausschließlich für diese Dateien
- Die Ergebnisliste wird für die selektierten Einträge live aktualisiert
- Nicht selektierte Einträge bleiben unverändert in der Liste
- Der Mini-Lauf verhält sich fachlich wie ein regulärer Lauf
gleiche Anwendungslogik, gleicher Use Case, nur eingeschränkte Dateimenge
---
### Button „Status zurücksetzen"
- **Aktion:** Nur DB-Status der selektierten Dateien zurücksetzen, keine sofortige Verarbeitung
- **Aktiv wenn:** Kein Lauf aktiv UND mindestens 1 Eintrag selektiert
- **Inaktiv wenn:** Lauf läuft ODER keine Selektion
- **Verhalten:**
- DB-Status aller selektierten Einträge wird zurückgesetzt
- Kein sofortiger Lauf
- Betroffene Zeilen bleiben in der Ergebnisliste sichtbar und erhalten die
Kennzeichnung **„Zurückgesetzt wartet auf nächsten Lauf"**
- Beim nächsten regulären Lauf werden die zurückgesetzten Dateien automatisch mitgenommen
- **Fehlerbehandlung:** Reset läuft nach **Best-effort**-Prinzip
erfolgreich zurückgesetzte Einträge werden zurückgesetzt, fehlgeschlagene bleiben
im alten Status; der Meldungs- und Zusammenfassungsbereich zeigt:
- Anzahl ausgewählter Einträge
- Anzahl erfolgreich zurückgesetzt
- Anzahl fehlgeschlagen
- Bei Fehlern: betroffene Dateinamen im Meldungsbereich
---
### Welche Status können zurückgesetzt werden
Alle Status sind zurücksetzbar:
| UI-Status | DB-Status | Zurücksetzbar | Verhalten im nächsten regulären Lauf |
|---|---|---|---|
| ✔ Erfolgreich | `DONE` | Ja | Wird erneut verarbeitet |
| ⚠ Fehlgeschlagen retryable | `FAILED_RETRYABLE` | Ja | Wird erneut verarbeitet |
| ✘ Fehlgeschlagen permanent | `FAILED_FINAL` | Ja | Wird erneut verarbeitet |
| ► Übersprungen | `DONE` | Ja | DB-Eintrag `DONE` wird zurückgesetzt, wird erneut verarbeitet |
---
### Verhalten bei vorhandener Zieldatei (Re-Run von DONE)
Wird eine bereits erfolgreich verarbeitete Datei erneut verarbeitet:
- **KI schlägt identischen Zieldateinamen vor** und Zieldatei ist bereits vorhanden:
Datei gilt als **✔ erfolgreich** kein neuer Eintrag im Zielordner, kein Fehler
- **KI schlägt anderen Namen vor:** Normale Verarbeitung
Dubletten-Suffix `(1)`, `(2)` wie im regulären Betrieb wenn nötig
---
### Verhalten bei fehlender oder verschobener Quelldatei
Ist die Quelldatei zum Zeitpunkt des Mini-Laufs nicht mehr vorhanden:
- Eintrag erhält Status **✘ permanent fehlgeschlagen**
- Meldung: „Quelldatei nicht gefunden: {Dateiname}"
- Kein weiterer Retry
---
### Verhalten während eines Mini-Laufs
- Der **Abbrechen-Button** (Soft-Stop aus V2.7) gilt auch für den Mini-Lauf
- Bei Soft-Stop:
- Bereits erfolgreich verarbeitete Einträge behalten ihren neuen Endstatus
- Noch nicht gestartete, aber bereits zurückgesetzte Einträge behalten den Status
„Zurückgesetzt wartet auf nächsten Lauf" und werden beim nächsten regulären Lauf mitgenommen
- Der Mini-Lauf endet im UI-Zustand „abgebrochen" mit Zusammenfassung
- Tab 1 „Konfiguration" wird während des Mini-Laufs gesperrt
- Fortschrittsbalken zeigt den Fortschritt des Mini-Laufs
Nenner entspricht der Anzahl der selektierten Dateien
- Während eines Mini-Laufs sind „Erneut verarbeiten" und „Status zurücksetzen" deaktiviert
- Kein zweiter paralleler Lauf ist startbar
---
### Scope dieser Funktion
Die Funktion gilt ausschließlich für Einträge der **sichtbaren Ergebnisliste der aktuellen Sitzung**.
Beim Programmstart erfolgt keine Rekonstruktion der Ergebnisliste aus der DB.
---
## Nicht in V2.8 enthalten
- Historien-Tab / SQLite-Ansicht (V3.0)
- Bearbeitung des KI-Titels in der GUI
- Manuelles Überschreiben eines Ergebnisses
- Massenoperationen außerhalb der Ergebnisliste
- Automatischer Re-Run nach Konfigurationsänderung
- Rekonstruktion der Ergebnisliste beim Programmstart
---
## Abnahmekriterien
- [ ] Jede Zeile der Ergebnisliste hat eine Checkbox
- [ ] Checkbox und Zeilenklick repräsentieren dieselbe Selektionsmenge
- [ ] Shift/Strg-Mehrfachselektion funktioniert wie im Windows Explorer
- [ ] „Alle auswählen"-Checkbox selektiert/deselektiert alle Einträge
- [ ] Alle vier Status sind selektierbar
- [ ] Während eines laufenden Mini-Laufs kann die Selektion nicht verändert werden
- [ ] Button „Erneut verarbeiten" ist nur aktiv wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
- [ ] Button „Status zurücksetzen" ist nur aktiv wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
- [ ] „Erneut verarbeiten" setzt DB-Status zurück und startet sofortigen Mini-Lauf nur für selektierte Dateien
- [ ] Der Mini-Lauf verarbeitet genau die beim Start selektierten Einträge spätere Selektionsänderungen haben keinen Einfluss
- [ ] „Status zurücksetzen" setzt nur den DB-Status zurück, betroffene Zeilen erhalten Kennzeichnung „Zurückgesetzt wartet auf nächsten Lauf"
- [ ] Reset-Ergebnis zeigt Anzahl ausgewählter, erfolgreich zurückgesetzter und fehlgeschlagener Einträge
- [ ] Bei identischem Zieldateinamen gilt der Eintrag nach Re-Run als ✔ erfolgreich
- [ ] Fehlende Quelldatei führt zu ✘ permanent fehlgeschlagen mit Meldung
- [ ] Mini-Lauf zeigt korrekten Fortschrittsbalken für die selektierte Dateimenge
- [ ] Abbrechen-Button (Soft-Stop) funktioniert auch während eines Mini-Laufs
- [ ] Nach Soft-Stop: bereits verarbeitete Einträge behalten neuen Status, nicht gestartete bleiben „Zurückgesetzt"
- [ ] Tab 1 ist während des Mini-Laufs gesperrt
- [ ] Nicht selektierte Einträge bleiben nach „Erneut verarbeiten" unverändert in der Liste
- [ ] Beim nächsten regulären Lauf nach „Status zurücksetzen" werden zurückgesetzte Dateien mitgenommen
- [ ] Während eines Mini-Laufs sind beide Buttons deaktiviert
- [ ] headless-Betrieb ist unverändert funktionsfähig
- [ ] `mvn clean verify` ist grün
+378
View File
@@ -0,0 +1,378 @@
# V2.9 Integrierte PDF-Vorschau und Dateinamen-Bearbeitung
**Status:** Freigegeben
**Erstellt:** 2026-04-24
**Überarbeitet:** 2026-04-24 (nach zwei ChatGPT-Reviews, finale Version)
**Autor:** Marcus (mit Claude als Mentor)
---
## Ziel
V2.9 erweitert den Tab „Verarbeitungslauf" um zwei eng verzahnte Funktionen:
1. **Integrierte PDF-Vorschau** beim Anklicken einer Zeile wird die erste Seite der
Quelldatei direkt im Detailbereich rechts gerendert (kein separates Fenster, kein
zusätzlicher Klick)
2. **Editierbarer Dateiname** der von der KI vorgeschlagene Dateiname kann direkt
in der GUI korrigiert werden, bevor er als endgültig gilt
Beide Funktionen zusammen ermöglichen einen natürlichen Review-Zyklus:
**KI benennt → Benutzer schaut rein → Benutzer korrigiert bei Bedarf → fertig.**
---
## Hintergrund
### Bisheriger Zustand
- Der Detailbereich rechts zeigt nur KI-Begründung als TextArea
- Ob der vorgeschlagene Dateiname sinnvoll ist, kann der Benutzer nur anhand des
KI-Reasonings beurteilen den tatsächlichen Dokumentinhalt sieht er nicht
- Der generierte Dateiname ist nach dem Lauf nicht mehr veränderbar
- Die Spike-Implementierung (PDFViewFX + jai-imageio-jpeg2000 für JBIG2-Unterstützung)
hat die technische Machbarkeit bereits bestätigt; der Spike-Code wird im Rahmen
von V2.9 durch produktionsreifen Code ersetzt
### Motivation
- Benutzer sollen schnell beurteilen können, ob der KI-Dateiname passt,
ohne ein externes Programm öffnen zu müssen
- Korrekturen sollen direkt in der Anwendung möglich sein für nicht-technische
Benutzer (z. B. Familienmitglieder) ist das eine wesentliche UX-Verbesserung
- Die Anwendung wird vom reinen Batch-Prozessor zum assistierten
Dokumenten-Review-Werkzeug weiterentwickelt
---
## Zielbild
Nach Abschluss von V2.9 kann der Benutzer:
1. Eine Zeile in der Ergebnisliste anklicken
2. Sofort die erste Seite der zugehörigen **Quelldatei** als Vorschau sehen
ohne weiteren Klick, direkt im Detailbereich
3. Weitere Seiten bei Bedarf **auf Anfrage** laden (Lazy Rendering)
4. Den vorgeschlagenen Dateinamen **direkt in der GUI bearbeiten** und speichern
5. Den headless-Betrieb unverändert nutzen V2.9 betrifft ausschließlich die GUI
---
## Layout-Änderung im Tab „Verarbeitungslauf"
### Bisheriges Layout
```
[ Fortschrittsbalken ]
[ Ergebnistabelle (~75% Breite) | KI-Begründung (~25%) ]
[ Buttons ]
[ Statuszeile ]
```
### Neues Layout
```
[ Fortschrittsbalken ]
[ Ergebnistabelle (~60% Breite) | Detailbereich (~40% Breite) ]
[ | KI-Begründung ]
[ | Dateiname (editierbar) ]
[ | PDF-Vorschau (Seite X/Y) ]
[ Buttons ]
[ Statuszeile ]
```
- Tabelle und Detailbereich sind durch einen **verschiebbaren Splitter** (SplitPane)
getrennt der Benutzer kann das Verhältnis anpassen
- Standard-Split: 60% Tabelle / 40% Detailbereich
- Der Detailbereich ist vertikal aufgebaut: KI-Begründung oben (kompakt),
darunter Dateiname-Feld, darunter PDF-Vorschau (nimmt verfügbaren Restplatz)
- Die PDF-Vorschau rendert die erste Seite **„fit to width"** Seitenverhältnis wird
beibehalten, die Seite füllt die verfügbare Panelbreite aus
---
## Fachliche Anforderungen
### PDF-Vorschau
#### Grundverhalten
- Beim Anklicken einer Zeile in der Ergebnisliste wird **automatisch** Seite 1 der
zugehörigen **Quelldatei** gerendert und im Vorschaubereich angezeigt
- Das Rendering erfolgt **asynchron im Hintergrund** die GUI bleibt während des
Ladens reaktionsfähig
- Während des Renderings wird ein **Ladeindikator** (z. B. ProgressIndicator) angezeigt
- Die Vorschau zeigt immer die **Quelldatei**, nicht die umbenannte Zieldatei
#### Lazy Rendering und Seitennavigation
- Beim ersten Anklicken einer Zeile wird **ausschließlich Seite 1** gerendert
- Unterhalb der Vorschau wird die aktuelle Seite sowie die Gesamtseitenzahl
angezeigt: „Seite 1 / 12"
- Navigation:
- Button **„Nächste Seite"** lädt und rendert die jeweils nächste Seite on-demand
- Button **„Vorherige Seite"** lädt die vorherige Seite
- Bereits gerenderte Seiten werden **gecacht** ein erneuter Wechsel auf eine
bereits gerenderte Seite erfordert kein erneutes Rendering
- Der Cache wird geleert wenn eine andere Zeile angeklickt wird
- Die Navigations-Buttons sind bei Seite 1 (Zurück) bzw. letzter Seite (Weiter)
deaktiviert
#### Abbruchverhalten bei schnellem Wechsel (Latest Preview Request Wins)
- Es gilt das Prinzip **„latest preview request wins"**: Wenn während eines laufenden
Renderings eine neue Vorschau-Anforderung eingeht sei es durch Selektionswechsel
oder durch Seitennavigation innerhalb derselben PDF wird das laufende Rendering
abgebrochen bzw. sein Ergebnis verworfen
- Nur das Ergebnis der zuletzt angeforderten Vorschau darf im Vorschaubereich landen
- Veraltete Render-Ergebnisse werden niemals angezeigt
#### Fehlerfälle PDF-Vorschau
| Situation | Verhalten |
|---|---|
| Quelldatei nicht mehr vorhanden | Meldung im Vorschaubereich: „Quelldatei nicht gefunden" |
| PDF nicht lesbar / korrupt | Meldung im Vorschaubereich: „PDF konnte nicht geöffnet werden" |
| PDF passwortgeschützt / verschlüsselt | Meldung im Vorschaubereich: „PDF ist passwortgeschützt und kann nicht angezeigt werden" |
| JBIG2-Bilder nicht vollständig dekodierbar | Seite wird teilweise gerendert; kein Fehler-Abbruch; kein Hinweis nötig |
| Kein Eintrag selektiert | Vorschaubereich zeigt neutralen Platzhaltertext |
#### Technische Grundlage
- Bibliothek: `com.dlsc.pdfviewfx:pdfviewfx` (bereits im Spike erfolgreich getestet)
- Zusatzabhängigkeit für JBIG2 und erweiterte Bildformate:
`com.github.jai-imageio:jai-imageio-jpeg2000` (bereits im Spike ergänzt)
- Der Spike-Code (`PdfViewerSpike.java`, Spike-Button in `GuiBatchRunTab`) wird
vollständig entfernt und durch die produktive Implementierung ersetzt
- Rendering läuft in einem dedizierten Background-Thread (nicht im JavaFX
Application Thread)
---
### Editierbarer Dateiname
#### Zustandsmodell
Der Dateiname-Bereich kennt drei klar getrennte Zustände:
| Zustand | Beschreibung |
|---|---|
| **KI-Vorschlag** | Der von der KI ursprünglich generierte Name unveränderlich in der DB gespeichert; dient als Referenz für „Zurücksetzen auf KI-Vorschlag" |
| **Letzter gespeicherter Name** | Der zuletzt per „Dateiname übernehmen" bestätigte Name (= aktueller FS- und DB-Stand); ist nach dem Batch-Lauf zunächst identisch mit dem KI-Vorschlag |
| **Aktuelle Eingabe** | Der aktuell im Textfeld eingetippte, noch nicht gespeicherte Wert |
**Anzeige-Regel:** Im Textfeld wird beim Selektieren einer Zeile immer der
**letzte gespeicherte Name** angezeigt nicht der KI-Vorschlag.
Wurde noch nie manuell gespeichert, sind beide identisch.
**Dirty-State-Regel:** Dirty-State besteht wenn die **aktuelle Eingabe** vom
**letzten gespeicherten Namen** abweicht. Der KI-Vorschlag ist keine Dirty-Basis.
#### Anzeige
- Unterhalb der KI-Begründung und oberhalb der PDF-Vorschau befindet sich ein
Bereich „Dateiname"
- Der Dateiname wird in einem **editierbaren Textfeld** (TextField) angezeigt
- Das Textfeld zeigt den **letzten gespeicherten Namen** ohne Dateierweiterung
(`.pdf` wird separat als nicht editierbares Label daneben angezeigt)
- Solange kein Eintrag selektiert ist, ist das Textfeld leer und deaktiviert
- Wenn das Textfeld vom letzten gespeicherten Namen abweicht (**Dirty State**),
wird dies durch eine visuelle Markierung am Textfeld angezeigt (z. B. farbiger Rand)
#### Tastatur- und Schaltflächen-Verhalten
| Aktion | Verhalten |
|---|---|
| **Enter** im Textfeld | Löst „Dateiname übernehmen" aus (sofern Validierung grün) |
| **Escape** im Textfeld | Verwirft aktuelle Eingabe; stellt **letzten gespeicherten Namen** wieder her |
| **„Dateiname übernehmen"** | Startet die atomare Speicher-Transaktion |
| **„Zurücksetzen auf KI-Vorschlag"** | Setzt das Textfeld auf den ursprünglichen KI-Vorschlag zurück (kein Speichern nur Textfeld-Inhalt) |
Hinweis: „Zurücksetzen auf KI-Vorschlag" und Escape haben **unterschiedliche Semantik**:
Escape = zurück zum letzten gespeicherten Stand; „Zurücksetzen" = zurück zum KI-Ursprung.
#### Speichern-Transaktion (Alles oder Nichts)
Das Speichern eines geänderten Dateinamens ist eine **atomare Operation** bestehend
aus zwei Persistenzschritten:
1. Zieldatei im Dateisystem umbenennen
2. Eintrag in der SQLite-DB aktualisieren
**Schlägt Schritt 1 oder 2 fehl, wird die gesamte Aktion abgebrochen:**
- Bereits durchgeführte Teilschritte werden zurückgerollt
- Dateisystem und DB bleiben im vorherigen Zustand
- Eine Fehlermeldung im Statusbereich informiert den Benutzer
- Das Textfeld behält den eingegebenen Wert der Benutzer kann es erneut versuchen
Nach erfolgreicher Transaktion (Projektionsschritt, nicht Teil der Transaktion):
- Tabellenspalte „Neuer Dateiname" wird aktualisiert
- Erfolgsmeldung im Statusbereich
Mögliche Fehlerursachen für Schritt 1: Datei-Lock durch andere Prozesse (Scanner, AV),
fehlende Schreibrechte, Read-only-Dateisystem, Netzlaufwerk nicht erreichbar.
#### Konfliktsemantik bei vorhandenem Zieldateinamen
Existiert im Zielordner bereits eine Datei mit dem neu eingegebenen Namen,
wird anhand des **Fingerprints** (SHA-256 des Dateiinhalts) entschieden:
| Situation | Verhalten |
|---|---|
| **Gleicher Fingerprint** | Dateien sind inhaltlich identisch → keine Aktion; Meldung im Statusbereich: „Identische Datei bereits vorhanden keine Umbenennung nötig"; weder FS noch DB werden geändert |
| **Unterschiedlicher Fingerprint** | Warnung im Statusbereich; Dateiname im FS erhält automatisch ein Suffix `(1)`, `(2)` usw.; DB wird mit dem tatsächlichen neuen Namen inkl. Suffix aktualisiert |
#### Validierung des Dateinamens
Folgende Prüfungen erfolgen **live während der Eingabe**:
| Prüfung | Verhalten bei Verletzung |
|---|---|
| Dateiname ist leer oder nur Leerzeichen | Speichern-Button deaktiviert, Hinweistext unterhalb des Feldes |
| Führende oder abschließende Leerzeichen | Speichern-Button deaktiviert, Hinweistext |
| Unerlaubte Zeichen (`\ / : * ? " < > \|`) | Speichern-Button deaktiviert, Hinweistext |
| Reservierte Windows-Namen (`CON`, `PRN`, `AUX`, `NUL`, `COM1``COM9`, `LPT1``LPT9`) | Speichern-Button deaktiviert, Hinweistext |
| Dateiname endet auf Punkt | Speichern-Button deaktiviert, Hinweistext |
| Dateiname + Zielpfad + `.pdf` überschreitet 259 Zeichen | Speichern-Button deaktiviert, Hinweistext |
Die 259-Zeichen-Grenze ist eine **bewusste Produktregel** für maximale
Windows-Kompatibilität (Windows MAX_PATH = 260 Zeichen inkl. Null-Terminator).
#### Zustände des Dateiname-Bereichs
| Zeilenstatus | Verhalten |
|---|---|
| Kein Eintrag selektiert | Textfeld leer, deaktiviert |
| Eintrag mit Status `DONE` (erfolgreich) | Textfeld editierbar, letzter gespeicherter Name vorausgefüllt |
| Eintrag mit Status `FAILED_*` | Textfeld leer, deaktiviert (kein Dateiname vorhanden) |
| Eintrag mit Status `SKIPPED` | Textfeld deaktiviert |
| Lauf aktiv | Textfeld deaktiviert, alle Buttons deaktiviert |
#### Verhalten bei fehlender Zieldatei
Ist die Zieldatei zum Zeitpunkt des Speicherns nicht mehr im Zielordner vorhanden:
- Schritt 1 der Transaktion schlägt fehl
- Gemäß Alles-oder-Nichts-Prinzip: DB wird **nicht** aktualisiert
- Fehlermeldung im Statusbereich: „Zieldatei nicht gefunden Umbenennung nicht möglich"
- Das Textfeld behält den eingegebenen Wert
#### Verhalten bei ungespeicherten Änderungen (Dirty State)
Ein Hinweisdialog erscheint, wenn der Benutzer mit aktivem Dirty-State eine der
folgenden Aktionen ausführt:
- Eine andere Zeile in der Ergebnistabelle anklicken
- Den Tab wechseln (Konfiguration ↔ Verarbeitungslauf)
- Die Anwendung schließen
- Einen neuen Lauf starten
Dialog-Text: „Der Dateiname wurde geändert aber nicht gespeichert. Änderungen verwerfen?"
Optionen: **„Verwerfen"** (Dirty State wird geleert, Aktion wird fortgesetzt) /
**„Zurück"** (Dialog schließt, Benutzer bleibt im Textfeld)
---
## Architektur
### Manuelle Namenskorrektur als Application-Use-Case
Die manuelle Dateinamen-Korrektur wird als **eigenständiger Application-Use-Case**
modelliert, nicht im GUI-Adapter implementiert:
- Ein neuer Use-Case `ManualFileRenameUseCase` (o. ä.) kapselt die atomare Transaktion
aus FS-Rename + DB-Update
- Der `GuiBatchRunCoordinator` (GUI-Adapter) delegiert ausschließlich an diesen Use-Case
- Dateisystem- und DB-Zugriffe laufen ausschließlich über bestehende oder neue
Ports/Adapter kein Direktzugriff aus dem GUI-Adapter
- Damit bleibt die hexagonale Architektur gewahrt und der Use-Case ist unabhängig
von der GUI testbar
### Komponenten-Übersicht
| Komponente | Änderung |
|---|---|
| `GuiBatchRunTab` | Hauptumbau: SplitPane, Detailbereich-Redesign, Spike-Code entfernen |
| `GuiBatchRunResultRow` | Neues Feld: `correctedFileName` als `Optional<String>` |
| `GuiBatchRunCoordinator` | Delegiert Dateinamen-Korrektur an neuen Use-Case |
| `ManualFileRenameUseCase` | Neuer Application-Use-Case: atomares FS-Rename + DB-Update |
| `pom.xml` (GUI-Modul) | PDFViewFX + jai-imageio-jpeg2000 bleiben; Spike-Klasse entfernen |
| Domain / Ports | Ggf. neuer Port für Datei-Rename-Operation erforderlich |
| Headless-Betrieb | Unberührt |
---
## Abhängigkeiten zwischen den Funktionen
- PDF-Vorschau und editierbarer Dateiname sind **unabhängig voneinander nutzbar**
- Beide beziehen sich auf den in der Ergebnistabelle selektierten Eintrag
- Beim Selektionswechsel mit Dirty-State: Hinweisdialog erscheint (siehe oben)
- PDF-Vorschau-Cache wird beim Selektionswechsel geleert
---
## Verhalten während eines laufenden Batch-Laufs
- Der Detailbereich (PDF-Vorschau + Dateinamen-Editor) ist **vollständig deaktiviert**
während ein regulärer Lauf oder Mini-Lauf aktiv ist
- Bereits angezeigte Vorschau bleibt sichtbar, aber Navigation und Bearbeitung
sind gesperrt
---
## Nicht in V2.9 enthalten
- Löschen der Quelldatei nach Bestätigung (spätere Version)
- Vollständiger PDF-Viewer mit freiem Scrollen und Zoom (Issue #23: DPI-Optimierung)
- Historien-Tab / SQLite-Ansicht (Issue #7, V3.0)
- Automatischer Scheduler / System-Tray (Issues #20, #22)
- Kompakteres Layout der Konfigurationsseite (Issue #24)
- Anwendungs-Icon (Issue #21)
---
## Abnahmekriterien
### Fachliche Akzeptanz
#### PDF-Vorschau
- [ ] Beim Anklicken einer Zeile wird Seite 1 der Quelldatei automatisch gerendert ohne extra Klick
- [ ] Während des Renderings ist ein Ladeindikator sichtbar; die GUI bleibt reaktionsfähig
- [ ] Die Vorschau rendert „fit to width" mit beibehaltenem Seitenverhältnis
- [ ] Seitenanzahl wird angezeigt: „Seite 1 / X"
- [ ] „Nächste Seite" / „Vorherige Seite" laden Seiten on-demand
- [ ] Bereits gerenderte Seiten werden gecacht; Selektionswechsel leert den Cache
- [ ] Navigations-Buttons sind korrekt deaktiviert (erste / letzte Seite)
- [ ] Schneller Selektionswechsel oder Seitenwechsel während Rendering: nur das zuletzt angeforderte Ergebnis wird angezeigt (latest preview request wins)
- [ ] Quelldatei nicht vorhanden → verständliche Fehlermeldung im Vorschaubereich
- [ ] PDF nicht lesbar / korrupt → verständliche Fehlermeldung im Vorschaubereich
- [ ] PDF passwortgeschützt → verständliche Fehlermeldung im Vorschaubereich
#### Dateiname-Editor
- [ ] Textfeld zeigt beim Selektieren den **letzten gespeicherten Namen** (nicht KI-Vorschlag) ohne `.pdf`-Erweiterung; `.pdf` als nicht editierbares Label daneben sichtbar
- [ ] Dateiname ist direkt im Textfeld editierbar
- [ ] Dirty-State (Abweichung von letztem gespeichertem Namen) wird visuell am Textfeld angezeigt
- [ ] Enter im Textfeld löst „Dateiname übernehmen" aus (wenn Validierung grün)
- [ ] Escape im Textfeld stellt den **letzten gespeicherten Namen** wieder her
- [ ] „Zurücksetzen auf KI-Vorschlag" setzt das Textfeld auf den KI-Ursprung zurück (ohne Speichern)
- [ ] Validierung prüft live: leer/nur Leerzeichen, führende/abschließende Leerzeichen, unerlaubte Zeichen, reservierte Windows-Namen, endet auf Punkt, Pfadlänge > 259
- [ ] Bei Validierungsfehler: Speichern-Button deaktiviert, Hinweistext sichtbar
- [ ] „Dateiname übernehmen" ist atomar: FS und DB werden beide aktualisiert oder nichts davon
- [ ] Bei Fehler in FS oder DB: kein Teilupdate, Rollback, Fehlermeldung im Statusbereich, Textfeld behält Eingabe
- [ ] Nach Erfolg: Tabellenspalte und Statusbereich aktualisiert (Projektionsschritt)
- [ ] Dateikonflikt mit gleichem Fingerprint → keine Aktion, Meldung „Identische Datei bereits vorhanden"
- [ ] Dateikonflikt mit unterschiedlichem Fingerprint → Warnung, Suffix `(1)` usw., DB mit tatsächlichem Namen
- [ ] Zieldatei fehlt → Fehlermeldung, weder FS noch DB werden geändert
- [ ] Ungespeicherte Änderungen bei Selektionswechsel → Hinweisdialog erscheint
- [ ] Ungespeicherte Änderungen bei Tabwechsel → Hinweisdialog erscheint
- [ ] Ungespeicherte Änderungen beim App-Schließen → Hinweisdialog erscheint
- [ ] Ungespeicherte Änderungen bei Laufstart → Hinweisdialog erscheint
- [ ] Status `FAILED_*` und `SKIPPED` → Dateiname-Textfeld deaktiviert
- [ ] Während eines aktiven Laufs: Detailbereich vollständig deaktiviert
### Technische DoD
- [ ] Spike-Button und `PdfViewerSpike.java` sind vollständig entfernt
- [ ] Tab „Verarbeitungslauf" zeigt Tabelle und Detailbereich nebeneinander (SplitPane, 60/40, verschiebbar)
- [ ] `ManualFileRenameUseCase` ist im Application-Modul implementiert und unabhängig von der GUI testbar
- [ ] headless-Betrieb ist unverändert funktionsfähig
- [ ] `mvn clean verify` ist grün
File diff suppressed because it is too large Load Diff
+924
View File
@@ -0,0 +1,924 @@
# V3.1 UX-Polish und Verlauf-Tab-Reife
**Status:** Zur Implementierung freigegeben
**Erstellt:** 2026-05-05
**Überarbeitet:** 2026-05-05 (nach ChatGPT-Review Runden 1, 2 und 3)
**Autor:** Marcus (mit Claude als Mentor)
---
## Ziel
V3.1 ist der konsequente Nachschlag zu V3.0: Was der Produkttest aufgedeckt hat,
wird hier bereinigt. Kein großes Architektur-Feature, kein neues Maven-Modul
**gezielter UX-Schliff und Robustheit**.
Schwerpunkte:
1. **Polieren** sichtbare Schwächen aus dem V3.0-Produkttest beheben
(#77, #80, #81, #83, #84, #88, #91)
2. **Verlauf-Tab reifen lassen** Suche, Mehrfachauswahl, DB-Neuanlage
(#82, #86, #87)
3. **Quick Win** Mausrad-Zoom im PDF-Viewer als kleiner,
wertvoller Gebrauchskomfort (#32)
Die fachliche Kernverarbeitung bleibt vollständig unverändert.
---
## Einordnung
V3.0 ist der abgeschlossene Ausgangspunkt. Hexagonale Architektur,
Modulstruktur, headless-Betrieb, `.properties`-Konfigurationswahrheit
und Flyway-DB-Evolution bleiben unangetastet.
V3.1 fügt **kein neues Maven-Modul** hinzu.
**Headless-Betrieb:** Der `adapter-in-cli`-Pfad erhält keine neue Bedienfunktion.
Er ist jedoch von der globalen Lock-File-Pfadauflösung (#91) und einer
ggf. notwendigen Flyway-Schemamigration (#88) betroffen beide Änderungen
wirken beim Programmstart, unabhängig von GUI oder CLI.
---
## Scope
### In V3.1 enthalten
| # | Thema | Kategorie |
|---|---|---|
| #77 | Fehlende Tooltips | UX |
| #80 | Dirty-Indikator für Konfigurations-Tab | UX |
| #81 | Enum-Werte statt deutscher Bezeichnungen (Status-ComboBox + Versuche-Tabelle) | UX |
| #82 | Verlauf-Tab: Live-Filter bei Suche | GUI |
| #83 | KI-Begründung bei SUCCESS-Versuch verwirrend leer | UX |
| #84 | Aktionsbuttons nach Laufende nicht sofort reaktiviert | Bug |
| #86 | Mehrfachauswahl im Verlauf-Tab (Strg+A, Strg+Klick, Shift+Klick) | GUI |
| #87 | Neue leere SQLite-Datenbank anlegen | GUI |
| #88 | FAILED_FINAL-Einträge zeigen keine Fehlerursache im Verlauf-Tab | UX |
| #91 | Lock-File relativer Pfad Fallback wie Log-Verzeichnis | Robustheit |
| #32 | Mausrad-Zoom in PDF-Vorschau | GUI |
### Explizit nicht in V3.1
- Automatischer Scheduler / Quellordner-Überwachung (#22) → V3.x
- PDF-Viewer Render-DPI (#23) → V3.2
- F1-Hilfe (#69) → V3.2
- Dark Mode (#70) → V3.x
- Log-Viewer in der GUI (#72) → V3.2
- Token- und Kosten-Tracking (#74) → V3.2
- Excel-Export (#75) → V3.2
- Automatische Update-Prüfung (#76) → V3.2
- Änderung der fachlichen Kernverarbeitung
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
---
## Unverrückbare Leitplanken (unverändert gegenüber V3.0)
- Java 21, Maven Multi-Module, hexagonale Architektur
- Shade-JAR als primäres Distributionsartefakt
- GUI ist Standardstart, `--headless` bleibt vollständig erhalten
- `.properties` bleibt die einzige Konfigurationswahrheit
- Kein Webserver, kein Applikationsserver
- GUI offiziell nur unter Windows; headless für Windows Server / Task Scheduler
- JavaFX-Threading: I/O auf Worker-Thread, UI-Updates via `Platform.runLater()`
- Kein JavaFX in Domain oder Application
- JavaDoc-Standard für alle neuen öffentlichen Ports, Use-Cases, DTOs und Adapter-Methoden
- Notwendige Code-Kommentare auf Deutsch; Logging auf Deutsch
- Flyway ist die einzige Schema-Evolutionsquelle (kein manuelles DDL im Code)
---
## Status-Mapping-Tabelle (unverändert gegenüber V3.0)
Diese Tabelle ist weiterhin die einzige autoritative Quelle für Status-Darstellung
in der GUI. Sie gilt verbindlich für alle V3.1-Features, die Statuswerte anzeigen
insbesondere #81 (Status-ComboBox, Versuche-Tabelle).
**Alle acht Statuswerte müssen vollständig unterstützt werden.**
Kein Enum-Rohname darf für Endnutzer sichtbar sein.
| Domain-Status (`ProcessingStatus`) | GUI-Icon | Farbe | GUI-Text (Tooltip) | Summary-Kategorie |
|---|---|---|---|---|
| `SUCCESS` | `✓` | Grün | „Erfolgreich verarbeitet und umbenannt." | erfolgreich |
| `FAILED_RETRYABLE` | `↻` | Orange | „Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht." | wird wiederholt |
| `FAILED_FINAL` | `×` | Rot | „Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch." | fehlgeschlagen |
| `SKIPPED_ALREADY_PROCESSED` | `≡` | Grau | „Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet." | übersprungen |
| `SKIPPED_FINAL_FAILURE` | `⊘` | Dunkelgrau | „Endgültig übersprungen nach wiederholten Fehlern." | endgültig übersprungen |
| `READY_FOR_AI` | `⟳` | Blau | „Wartet auf Verarbeitung." | |
| `PROPOSAL_READY` | `◇` | Hellblau | „KI-Vorschlag liegt vor, wartet auf Bestätigung." | |
| `PROCESSING` | `▶` | Hellgrau | „Wird gerade verarbeitet." | |
**Wichtig:** Farbe ist niemals das einzige Unterscheidungsmerkmal.
Icon und Tooltip-Text müssen den Status allein eindeutig beschreiben.
---
## UX-Polishing-Features
### #77 Fehlende Tooltips
#### Problem
Der V3.0-Produkttest hat GUI-Elemente identifiziert, die noch keinen Tooltip
tragen. Die Infrastruktur (`GuiTooltipTexts`, `setTooltip()`) existiert bereits
aus #66 es fehlt nur die konsequente Anwendung.
#### Lösung
Vor der Implementierung führt Claude Code eine **vollständige Bestandsaufnahme**
durch: Alle interaktiven Elemente auf allen Tabs werden gegen vorhandene Tooltips
geprüft. Maßgeblich ist die Bestandsaufnahme die Zahl 16 stammt aus dem
Produkttest und ist nicht bindend. Werden mehr fehlende Elemente gefunden,
werden alle ergänzt.
Fehlende Tooltips werden in `GuiTooltipTexts` als Konstanten ergänzt und
im jeweiligen GUI-Tab via `element.setTooltip(new Tooltip(GuiTooltipTexts.XY))`
gesetzt. Keine hartcodierten Strings.
**Tooltips auf `TableColumn`-Headern (Sonderfall JavaFX):**
`TableColumn` ist kein normaler JavaFX-Node; `setTooltip()` ist darauf nicht
direkt anwendbar. **Kein Skin-/Lookup-Hack.** Falls Header-Tooltips benötigt
werden, wird ein `Label` als Column-Graphic gesetzt:
```java
Label headerLabel = new Label("Spaltenname");
headerLabel.setTooltip(new Tooltip("Erklärungstext"));
column.setGraphic(headerLabel);
column.setText("");
```
Bei der Umsetzung muss geprüft werden, dass Sortierung, Header-Breite
und bestehendes CSS durch das Column-Graphic-Pattern nicht sichtbar
verschlechtert werden.
Falls das Projekt bereits eine stabile eigene Lösung für Column-Tooltips
besitzt, wird diese wiederverwendet.
**Zu prüfende Tabs und Elemente (Anhaltspunkte):**
| Tab | Verdächtige Elemente |
|---|---|
| Verlauf | Tabellenspalten-Header, Suchfeld, Such-Button, Aktions-Buttons (Reset, Löschen) |
| Verlauf (Detail) | Status-Icon, Versuche-Tabelle Spalten, KI-Begründung-Bereich |
| Prompt | Speichern-Button, Zurücksetzen-Button, TextArea |
| Allgemein | Fortschrittsbalken, Summary-Banner-Elemente |
**Technisch:** Ausschließlich `adapter-in-gui` und `GuiTooltipTexts`.
Keine Architektur-Änderungen.
---
### #80 Dirty-Indikator für Konfigurations-Tab
#### Problem
Der Prompt-Tab zeigt bereits einen `*`-Dirty-Indikator im Tab-Titel und warnt
beim Verlassen mit ungespeicherten Änderungen. Der Konfigurations-Tab hat dieses
Verhalten nicht Nutzer verlieren versehentlich Änderungen.
#### Lösung
**Dirty-State-Tracking mit Baseline-Snapshot:**
Beim Laden einer Konfiguration wird ein **Baseline-Snapshot** des geladenen Zustands
gespeichert. Dirty-State entsteht durch Vergleich des aktuellen Formularinhalts
mit dem Snapshot nicht durch blindes „erster Listener feuert".
Während programmgesteuertem Laden oder Normalisieren von Feldinhalten wird
Dirty-Tracking temporär unterdrückt (Flag `loadingInProgress`), damit
programmatische Feldänderungen keinen unechten Dirty-State auslösen.
- Beim ersten echten Nutzerwechsel gegenüber dem Snapshot: Tab-Titel wechselt
auf `* Konfiguration`
- Dirty-Flag wird zurückgesetzt bei: Speichern, Speichern unter,
Laden einer neuen Konfiguration (nach Bestätigungsdialog)
**Bestätigungsdialog bei Navigation mit Dirty State:**
Beim Laden einer neuen Konfiguration oder beim Schließen der Anwendung
mit ungespeicherten Konfig-Änderungen:
> „Die Konfiguration enthält ungespeicherte Änderungen. Jetzt speichern?"
> [Speichern] [Verwerfen] [Abbrechen]
**Kopplung mit #87 (Neue Datenbank):**
Legt der Nutzer über „Neue Datenbank anlegen..." eine neue DB-Datei an,
wird der DB-Pfad im Konfigurationsmodell geändert und der Konfig-Tab
in den Dirty-State versetzt. Der bestehende Bestätigungsdialog greift
beim nächsten Schließen oder Ladevorgang.
**UX-Konsistenz mit Prompt-Tab:**
Die UX muss identisch zum Prompt-Tab sein: Sternchen im Tab-Titel,
Warn-/Speicherdialog beim Verlassen, Rücksetzen nach Speichern.
Die **technische Umsetzung** darf im Konfig-Tab über Baseline-Snapshot
und `loadingInProgress` erfolgen, wenn die komplexere Formularlogik
das erfordert.
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
---
### #81 Enum-Werte statt deutscher Bezeichnungen
#### Problem
Die Status-ComboBox im Verlauf-Tab zeigt rohe Enum-Namen (`READY_FOR_AI`,
`FAILED_FINAL` etc.). Die Versuche-Tabelle im Detailbereich zeigt ebenfalls
Enum-Rohnamen in der Status-Spalte. Das ist für Endnutzer unlesbar.
#### Lösung
**Anzeige-Mapping:**
`ProcessingStatusPresentation` (existiert bereits aus #51) stellt die Mapping-Logik
bereit. Dieses Mapping wird für alle Statusanzeigen im Verlauf-Tab verbindlich genutzt.
**Alle acht Statuswerte der autoritativen Tabelle müssen abgedeckt sein:**
| Enum-Wert | Angezeigter Text |
|---|---|
| `SUCCESS` | „✓ Erfolgreich" |
| `FAILED_RETRYABLE` | „↻ Temporärer Fehler" |
| `FAILED_FINAL` | „× Dauerhaft fehlgeschlagen" |
| `SKIPPED_ALREADY_PROCESSED` | „≡ Bereits verarbeitet" |
| `SKIPPED_FINAL_FAILURE` | „⊘ Endgültig übersprungen" |
| `READY_FOR_AI` | „⟳ Wartet auf Verarbeitung" |
| `PROPOSAL_READY` | „◇ Vorschlag vorhanden" |
| `PROCESSING` | „▶ In Bearbeitung" |
**Status-ComboBox:**
- Erster Eintrag: „Alle Status" GUI-intern als `Optional.empty()` bzw. `null`-Filter
behandelt; kein Domain-Enum-Wert
- Weitere Einträge: alle acht Statuswerte mit Displaytext
- Intern wird für DB-Queries stets der Enum-Name verwendet
- `StringConverter<ProcessingStatus>` implementieren
**Versuche-Tabelle (Detailbereich):**
- Status-Spalte: `ProcessingStatusPresentation`-Mapping anwenden
- Kein Enum-Rohname darf für Endnutzer sichtbar sein
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
---
### #83 KI-Begründung bei SUCCESS-Versuch verwirrend leer
#### Problem
Im Detailbereich wird bei einem Versuch mit Status `SUCCESS` die
KI-Begründungs-TextArea leer angezeigt. Nutzer verstehen nicht, ob das
ein Fehler ist oder ob tatsächlich keine Begründung vorliegt.
#### Lösung
**Platzhalter über JavaFX `promptText` (kein echter Textinhalt):**
Bei leerem oder null `ai_reasoning` gilt:
```java
textArea.setText("");
textArea.setPromptText("Keine KI-Begründung für diesen Versuch gespeichert.");
```
Der `promptText` wird von JavaFX automatisch gedimmt dargestellt und ist
**nicht kopierbar, nicht speicherbar, nicht als Nutzdaten behandelbar**.
Kein Vermischen von Daten und UI-Platzhaltertext.
Die TextArea bleibt sichtbar ein leeres Feld ohne Erklärung ist schlechter
als ein erklärender Platzhalter.
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case,
keine DB-Änderung.
---
### #84 Aktionsbuttons nach Laufende nicht sofort reaktiviert
#### Problem
Nach Abschluss eines Verarbeitungslaufs bleiben die Aktionsbuttons im Verlauf-Tab
(„Status zurücksetzen", „Eintrag löschen") dauerhaft deaktiviert.
#### Lösung
**Ereignisgetriebene Button-State-Neuberechnung:**
Der Button-State wird nach jedem Lauf-Terminierungsereignis neu berechnet
unabhängig vom Grund der Terminierung:
- Erfolgreicher Laufabschluss
- Fehlerabbruch (Exception im Worker)
- Nutzerabbruch
- Leerlauf (keine Dateien zu verarbeiten)
Nach Terminierung wird, sofern eine Auswahl in der Verlauf-Tabelle besteht,
der zugehörige Aktionsbutton-State **ereignisgetrieben** aktiviert
ohne dass der Nutzer die Auswahl erneuern oder den Tab wechseln muss.
**Code-Analyse erforderlich:** Claude Code analysiert den genauen Signal-Pfad
(Laufabschluss-Event → UI-Komponente) und korrigiert die fehlende
`Platform.runLater()`-Kopplung.
**Technisch:** Vermutlich `adapter-in-gui` und ggf. `bootstrap` (Bridge-Verdrahtung).
Kein neuer Port, kein Use-Case.
---
### #88 FAILED_FINAL ohne Fehlerursache im Verlauf-Tab
#### Problem
Der Detailbereich zeigt bei `FAILED_FINAL`-, `FAILED_RETRYABLE`- und
`SKIPPED_FINAL_FAILURE`-Einträgen keine Fehlerursache an.
Der Nutzer sieht nur den Status-Icon.
#### Lösung
**Schema-/Code-Analyse als blockierender erster Schritt:**
Vor jeder weiteren Implementierung dokumentiert Claude Code verbindlich,
welcher Fall vorliegt:
**Fall A geeignetes Fehlerfeld bereits vorhanden:**
`processing_attempt` enthält bereits ein nutzbares Fehlerfeld.
→ Keine Migration. GUI und Abfrage werden um die Anzeige erweitert.
**Fall B kein geeignetes Fehlerfeld vorhanden:**
→ Flyway-Migration mit der **nächsten freien Versionsnummer** zum Zeitpunkt
der Implementierung. Fehlerdetails können nur für ab V3.1 erzeugte
Verarbeitungsversuche gespeichert werden. Bestehende Einträge bleiben
unverändert und zeigen den Platzhalter „Keine Fehlerdetails gespeichert."
**Fall C Fehlerdetails werden bisher nur im Log gespeichert:**
→ Migration zwingend erforderlich. Zusätzlich muss der Fehlerpfad der
Verarbeitungslogik um Persistierung der Fehlerdetails erweitert werden.
**Domain-Modul-Einschränkung:**
`pdf-umbenenner-domain` bleibt unverändert, sofern die benötigten
Fehlerdetails ausschließlich über bestehende oder application-nahe
History-DTOs transportiert werden können.
Falls das fachliche Attempt-Modell im Domain-Modul liegt und für die
Anzeige erweitert werden muss, ist eine **minimale Domain-Erweiterung zulässig**.
Keine Änderung an der fachlichen Kernverarbeitung.
**Datenmodell (bei Migration Fall B oder C):**
```sql
-- Versionsnummer = nächste freie Flyway-Version zum Zeitpunkt der Implementierung
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
```
`failure_details` enthält eine **nutzerverständliche, gekürzte Fehlerbeschreibung**.
Provider- oder Exception-Meldungen werden **nicht roh persistiert**
gespeichert wird eine kontrolliert erzeugte Kurzmeldung aus bekannten
Fehlerkategorien oder eine bereinigte/gekürzte Message ohne Stacktrace,
API-Keys oder vollständige Provider-Rohantworten.
Die Begrenzung auf **1000 Zeichen wird spätestens vor Persistierung im
DB-Adapter erzwungen**: Längere Texte werden gekürzt und mit „…" markiert.
Falls bereits vorher ein zentrales Fehler-Mapping existiert, darf dort
gekürzt werden. Entscheidend: in die DB gelangen nur gekürzte, bereinigte
Fehlerdetails. Kein SQL-`CHECK`-Constraint (um Alt-/Importdaten nicht
zu blockieren).
**„Letzter Versuch" Definition:**
Die angezeigte Fehlerursache stammt aus dem Versuch mit dem höchsten
`attempt_number`. Bei Gleichstand wird der mit dem jüngsten `ended_at` verwendet.
Die Sortierung wird im Rahmen der Code-Analyse gegen das vorhandene Schema
verifiziert. Falls `attempt_number` oder `ended_at` nicht existieren, wird
die technisch eindeutige Sortierung des Attempt-Verlaufs verwendet und
in der Implementierungsnotiz dokumentiert.
**Anzuzeigende Status:**
Fehlerursache wird angezeigt bei:
- `FAILED_FINAL`
- `FAILED_RETRYABLE`
- `SKIPPED_FINAL_FAILURE` (zeigt die letzte bekannte Fehlerursache des
zugrundeliegenden fehlgeschlagenen Attempts fachlich konsistent,
da `SKIPPED_FINAL_FAILURE` direkte Folge eines endgültigen Fehlschlags ist)
Bei fehlendem `failure_details` (NULL oder leer): Platzhaltertext via `promptText`
analog zu #83.
**Technisch:** `adapter-in-gui` (Anzeige), ggf. `adapter-out-db`
(Abfrage-Erweiterung), ggf. Flyway-Migration, ggf. minimale Domain-Erweiterung.
---
### #91 Lock-File relativer Pfad
#### Problem
Der Lock-Mechanismus nutzt einen konfigurierten oder Standard-Pfad für die
Lock-Datei. Bei relativem Pfad ist das Verzeichnis abhängig vom aktuellen
Arbeitsverzeichnis. Liegt die JAR unter `C:\Program Files`, ist das Verzeichnis
zudem nicht beschreibbar.
#### Lösung
**Verhalten abhängig vom Pfadtyp:**
**Absolut konfigurierter Pfad:**
Wird unverändert verwendet. Schlägt das Anlegen fehl, erfolgt **kein Fallback**
der Nutzer hat den Speicherort explizit vorgegeben. Start bricht mit klarer
Fehlermeldung ab.
**Relativer oder nicht konfigurierter (Default-)Pfad zweistufige Fallback-Strategie:**
1. **Primär:** Auflösung relativ zum Verzeichnis der JAR-Datei
(`CodeSource.getLocation()`)
2. **Fallback:** Auflösung relativ zu `user.home`
3. **Abbruch:** Erst wenn auch `user.home` fehlschlägt
**Parent-Verzeichnisse** werden bei Bedarf automatisch angelegt
(`Files.createDirectories()`).
Der final verwendete **absolute Pfad wird beim Start geloggt** (INFO-Level):
```
Lock-Datei: C:\Users\Funny\Documents\pdf-umbenenner.lock
```
**Gilt für GUI- und Headless-Start.**
**Code-Analyse erforderlich:** Claude Code ermittelt die aktuelle
Lock-Implementierungslokation (`bootstrap` oder `adapter-out-db`).
---
## GUI-Features
### #82 Verlauf-Tab: Live-Filter bei Suche
#### Problem
Die Suche im Verlauf-Tab wird nur durch expliziten Klick auf den Such-Button
ausgelöst. Das erfordert unnötige Interaktion bei jeder Suchanpassung.
#### Lösung
**Live-Filter mit Debounce und Generation-Counter:**
- Das Suchfeld erhält einen `ChangeListener` auf die `textProperty()`
- Bei jeder Texteingabe startet ein JavaFX-`Timeline`-Debounce-Timer (300 ms)
- Nach 300 ms ohne weitere Eingabe wird die DB-Abfrage auf einem Worker-Thread gestartet
**Race-Condition-Schutz via Generation-Counter:**
Jede gestartete Suchanfrage erhält eine aufsteigende Generations-ID (atomarer
`long`-Counter). Der Worker-Thread trägt seine Generations-ID ins Ergebnis.
Beim `Platform.runLater()`-Callback wird das Ergebnis nur in die UI übernommen,
wenn die Generations-ID noch aktuell ist veraltete Worker-Ergebnisse
werden verworfen.
**Such-Button und Enter-Taste:**
- Klick auf Such-Button oder Enter im Suchfeld: Debounce-Timer sofort abgebrochen,
Suche unverzüglich gestartet
- Barrierefreiheit: Such-Button bleibt erhalten
**Auswahlverhalten nach neuen Suchergebnissen:**
Nach jeder Übernahme neuer Suchergebnisse wird die Tabellenauswahl
**vollständig geleert**. Detailbereich und Aktionsbuttons werden entsprechend
zurückgesetzt. Das ist robuster als ein Abgleich der alten Auswahl gegen
die neue Ergebnisliste und vermeidet Wechselwirkungen mit #86.
**Leeres Suchfeld:** Zeigt alle Einträge (bis LIMIT 501).
**Technisch:** Ausschließlich `adapter-in-gui`. Die bestehende Suchabfrage via
`GuiHistoryOverviewPort` wird unverändert wiederverwendet.
---
### #86 Mehrfachauswahl im Verlauf-Tab
#### Problem
Der Verlauf-Tab erlaubt nur Einzelauswahl. Bulk-Operationen sind nicht möglich.
#### Lösung
**Multi-Select-Modus:**
```java
tableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
```
JavaFX stellt damit Strg+Klick und Shift+Klick automatisch bereit.
**Strg+A Fokusabhängig:**
Strg+A selektiert alle sichtbaren Tabelleneinträge **nur, wenn die Verlauf-Tabelle
den Fokus besitzt**. Liegt der Fokus im Suchfeld, bleibt Strg+A die normale
Textauswahl im Suchfeld.
**Detailbereich bei Mehrfachauswahl:**
- Genau 1 Eintrag: Detailbereich wie bisher
- Mehrere Einträge: Platzhaltertext „X Einträge ausgewählt."
**Snapshot der fachlichen Schlüssel vor Worker-Thread-Start:**
Vor dem Start einer Bulk-Operation wird ein **unveränderlicher Snapshot der
fachlichen Schlüssel** erstellt, die die bestehenden Reset-/Delete-Use-Cases
erwarten (typischerweise Fingerprints, sofern das die vorhandene Use-Case-Signatur
erwartet). Der Worker-Thread arbeitet ausschließlich auf diesem Snapshot
nie auf einer Live-`ObservableList`, die sich während der Operation ändern könnte.
**Aktionsbuttons bei Mehrfachauswahl:**
| Aktion | Verhalten |
|---|---|
| „Status zurücksetzen" | Aktiv bei ≥ 1 Auswahl; Bestätigungsdialog: „X Einträge zurücksetzen?" |
| „Eintrag löschen" | Aktiv bei ≥ 1 Auswahl; Bestätigungsdialog: „X Einträge unwiderruflich löschen?" |
**Bulk-Fehlerstrategie (Partial Success):**
Schlägt eine Operation bei einzelnen Einträgen fehl, werden die restlichen
trotzdem abgearbeitet. Nach Abschluss erscheint ein **kompakter**
Zusammenfassungsdialog:
> „X von Y Einträgen erfolgreich verarbeitet. Z Einträge konnten nicht
> verarbeitet werden."
Detaillierte Einzelfehler werden geloggt, nicht in den Dialog gestopft.
**Ausführung:** Bulk-Operationen rufen die bestehenden Use-Cases
(`DefaultResetDocumentStatusUseCase`, `DefaultDeleteDocumentHistoryUseCase`)
sequenziell auf dem Worker-Thread auf. Keine neuen Use-Cases erforderlich.
**Sperren während Lauf:** Alle Aktions-Buttons deaktiviert während eines
aktiven Verarbeitungslaufs.
**Technisch:** Ausschließlich `adapter-in-gui`. Keine neuen Ports oder Use-Cases.
---
### #87 Neue leere SQLite-Datenbank anlegen
#### Problem
Will der Nutzer mit einer frischen Datenbank starten, muss er die Datei
manuell löschen. Das ist umständlich und fehleranfällig.
#### Lösung
**Neuer Menüpunkt:**
`Datenbank → Neue Datenbank anlegen...`
(Nur aktiv wenn kein Verarbeitungslauf läuft.)
**Eigentümer des aktiven Datenbankkontexts:**
Der Runtime-Wechsel der aktiven Datenbank erfordert eine zentrale Komponente,
die den aktiven Datenbankkontext besitzt. Vor der Implementierung analysiert
Claude Code, ob eine solche Komponente bereits existiert.
- **Fall A wechselbarer DB-Kontext vorhanden:** Vorhandene Komponente
wird genutzt/erweitert.
- **Fall B kein wechselbarer DB-Kontext vorhanden:** Es wird ein minimaler
`ActiveDatabaseContextPort` eingeführt (Outbound-Port in `application`,
Adapter in `bootstrap` oder `adapter-out-db`). Dieser Port ist die einzige
Stelle, an der die aktive DB-Referenz umgestellt wird.
**Der DB-Wechsel darf nicht im JavaFX-Code versteckt werden.**
Der Use-Case `DefaultCreateNewDatabaseUseCase` orchestriert den Wechsel;
die physische Umstellung der Verbindung delegiert er über den Port.
**Ablauf (atomar aus Anwendungssicht):**
1. `FileChooser` öffnet (Filter: `*.sqlite`); Nutzer wählt Zieldatei
2. **Pfad-Sicherheitsprüfung:**
Die aktive DB und die gewählte Zieldatei werden über **normalisierte,
absolut aufgelöste Pfade** verglichen kein Rohstring-Vergleich.
Für existierende Dateien wird `toRealPath()` verwendet; für noch nicht
existierende Dateien wird der Parent-Pfad real aufgelöst und der Dateiname
normalisiert verglichen. Unter Windows erfolgt der Vergleich case-insensitive.
Bei Übereinstimmung: klare Fehlermeldung, kein Überschreiben.
3. Existiert die Zieldatei (andere als aktive DB): Bestätigungsdialog
„Die Datei existiert bereits. Überschreiben?"
4. **GUI-Sperre:** Während Anlage und Wechsel befindet sich die GUI in einem
`DB-Busy`-Zustand. Alle DB-lesenden und DB-schreibenden Aktionen
(Live-Suche, Bulk-Reset, Bulk-Delete, Verlauf-Refresh, erneuter
Klick auf „Neue Datenbank anlegen") sind deaktiviert. Der Zustand
wird nach Erfolg oder Fehler zuverlässig zurückgesetzt.
5. Neue SQLite-Datei wird als **temporäre Datei im Zielverzeichnis** erzeugt
6. Flyway führt alle verfügbaren Migrationsskripte gegen die temporäre Datei aus
(`migrate()` auf neuesten Schema-Stand)
7. Neue DB-Verbindung wird **testweise geöffnet und geprüft** (gegen Temp-Datei).
Der Verbindungstest prüft mindestens:
- SQLite-Verbindung kann geöffnet werden
- Flyway-Schema-History ist vorhanden
- Eine einfache Leseabfrage gegen Schema-Metadaten ist erfolgreich
8. Erst nach erfolgreichem Test: temporäre Datei zur Zieldatei verschoben.
Bei bereits existierender, bestätigter Zieldatei wird
`Files.move(tempFile, targetFile, ATOMIC_MOVE, REPLACE_EXISTING)` verwendet,
sofern vom Dateisystem unterstützt. Die vorhandene Zieldatei wird vorher
**nicht separat gelöscht**. Wird die Kombination `ATOMIC_MOVE + REPLACE_EXISTING`
nicht unterstützt, bricht der Vorgang mit klarer Fehlermeldung ab
kein unsicherer halb-atomarer Fallback.
9. Aktive DB-Referenz der Anwendung umgestellt (via `ActiveDatabaseContextPort`)
10. Verlauf-Tab neu geladen → zeigt „Noch keine Verarbeitungen vorhanden."
11. Statuszeile aktualisiert DB-Pfad
12. DB-Pfad im Konfigurationsmodell geändert → Konfig-Tab wechselt in Dirty-State
13. Statuszeile oder Meldungsbereich zeigt:
„Neue Datenbank ist aktiv. Konfiguration speichern, damit diese DB
beim nächsten Start verwendet wird."
**Fehlerfall ohne partielle Änderung:**
Schlägt ein Schritt (Anlegen, Flyway, Verbindungstest, Move) fehl, bleibt die
bisher aktive DB **vollständig unverändert in Betrieb**. Die temporäre Datei
wird gelöscht. Fehlerdialog mit konkreter Meldung.
**Headless:** Die Funktion ist ausschließlich GUI-seitig aufrufbar.
`adapter-in-cli` ist nicht betroffen.
**Architektur:**
| Komponente | Typ | Modul | Zweck |
|---|---|---|---|
| `CreateNewDatabaseUseCase` | Inbound-Port-Interface | `application` | Vertrag: `createNewDatabase(Path)` |
| `DefaultCreateNewDatabaseUseCase` | Use-Case-Impl. | `application` | Atomarer DB-Wechsel: Temp-Datei, Flyway, Test, Move, Kontext-Umstellung |
| `DatabaseCreationPort` | Outbound-Port | `application` | `createAndInitialize(Path tempFile)` |
| `ActiveDatabaseContextPort` | Outbound-Port | `application` | `switchActiveDatabase(Path newDbFile)` Eigentümer des Laufzeitkontexts |
| `GuiCreateNewDatabasePort` | Bridge-Interface | `adapter-in-gui` | Brücke zum Use-Case |
| `SqliteDatabaseCreationAdapter` | Outbound-Adapter | `adapter-out-db` | SQLite-Temp-Datei erzeugen, Flyway migrate auf latest, Verbindung testen |
| `SqliteActiveDatabaseContextAdapter` | Outbound-Adapter | `bootstrap` oder `adapter-out-db` | Umschalten der aktiven DB-Referenz (Analyse erforderlich) |
---
### #32 Mausrad-Zoom in PDF-Vorschau
#### Problem
Die PDF-Vorschau lässt sich nur über die Zoom-Buttons skalieren.
Ein Mausrad-Zoom fehlt.
#### Lösung
**Scroll-Event auf der PDF-Vorschau-Komponente:**
```java
scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
if (event.isControlDown()) {
accumulateAndApplyZoomDelta(event.getDeltaY());
event.consume(); // immer konsumieren bei Strg, kein paralleles Scrollen
}
// ohne Strg: normales Scrollen bleibt
});
```
**Bei gedrückter Strg-Taste werden ScrollEvents grundsätzlich konsumiert**,
damit kein paralleles Scrollen im ScrollPane erfolgt auch wenn der Delta
zu klein für einen Zoomschritt ist.
**Delta-Akkumulation für Trackpad-Kompatibilität:**
Sehr kleine Trackpad-Deltas werden **intern akkumuliert**, bis die Mindestschwelle
für einen Zoomschritt erreicht ist. Kein Verwerfen: akkumulierte Deltas
ergeben bei genug Trackpad-Wischbewegung sauber einen Zoomschritt.
Als Orientierungswert gilt ±10 % je „Notch" eines Standard-Mausrads.
**Zoom-Verhalten:**
| Parameter | Wert |
|---|---|
| Auslöser | Strg + Mausrad |
| Schrittweite | Vorzeichenbasiert auf akkumuliertem `deltaY`, ca. 10 % je Notch |
| Minimum | 10 % |
| Maximum | 500 % |
| Zurücksetzen bei neuem PDF | Ja (Zoom auf Fit-to-Width) |
**Fit-to-Width-Modus:**
Nach manuellem Strg+Mausrad-Zoom verlässt die Vorschau den Fit-to-Width-Modus.
Fit-to-Width wird erst wieder aktiv, wenn ein neues PDF geladen oder der
Fit-to-Width-Button explizit erneut betätigt wird.
**Viewport-Stabilität:**
Beim Zoom bleibt die sichtbare Viewport-Mitte möglichst erhalten.
**Zoom-State-Konsistenz:**
Der Zoom-State wird über dieselbe Variable geführt, die auch die
Toolbar-Zoom-Buttons bedienen.
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
---
## Architektur-Zusammenfassung
### Neue Inbound-Port-Interfaces und Use-Cases
| Komponente | Typ | Modul | Zweck | Issue |
|---|---|---|---|---|
| `CreateNewDatabaseUseCase` | Inbound-Port-Interface | `application` | Vertrag für DB-Anlage | #87 |
| `DefaultCreateNewDatabaseUseCase` | Use-Case-Impl. | `application` | Atomarer DB-Wechsel via Temp-Datei + Port-Delegation | #87 |
### Neue Outbound-Ports
| Komponente | Modul | Zweck | Issue |
|---|---|---|---|
| `DatabaseCreationPort` | `application` | Temp-Datei erzeugen, Flyway, Verbindungstest | #87 |
| `ActiveDatabaseContextPort` | `application` | `switchActiveDatabase(Path)` Laufzeit-DB-Kontext | #87 |
### Neue Bridge-Interfaces (adapter-in-gui)
| Interface | Zweck | Issue |
|---|---|---|
| `GuiCreateNewDatabasePort` | Brücke zur DB-Anlage | #87 |
### Neue Adapter
| Adapter | Modul | Zweck | Issue |
|---|---|---|---|
| `SqliteDatabaseCreationAdapter` | `adapter-out-db` | SQLite-Temp-Datei, Flyway migrate auf latest, Test | #87 |
| `SqliteActiveDatabaseContextAdapter` | `bootstrap` oder `adapter-out-db` | Umschalten der aktiven DB-Referenz (Lokation via Code-Analyse) | #87 |
### Geänderte Komponenten (adapter-in-gui)
| Komponente | Änderung | Issues |
|---|---|---|
| `GuiHistoryTab` | Multi-Select + Schlüssel-Snapshot, Live-Filter + Generation-Counter + Auswahl leeren, Fehlerursache, Platzhalter via promptText, Tooltips, DB-Busy-Sperre | #82, #83, #86, #88, #77, #87 |
| `GuiConfigTab` | Dirty-State mit Baseline-Snapshot + loadingInProgress, Tab-Titel, Dialog, Kopplung mit #87 | #80 |
| `GuiTooltipTexts` | Neue Tooltip-Konstanten; TableColumn-Header via Column-Graphic-Pattern | #77 |
| Verlauf-Detailbereich | Enum-Displaytext (alle 8 Werte), Fehlerursache für FAILED/SKIPPED_FINAL | #81, #88 |
| Status-ComboBox | `StringConverter<ProcessingStatus>`, „Alle Status" als GUI-interner Null-Filter | #81 |
| PDF-Vorschau-Komponente | Delta-Akkumulation, Strg+Scroll konsumiert, Viewport-Stabilität, Fit-to-Width-Modus | #32 |
| Lauf-Abschluss-Signalkette | Ereignisgetriebene Button-State-Neuberechnung für alle Terminierungsgründe | #84 |
### Geänderte Komponenten (sonstige)
| Komponente | Modul | Änderung | Issue |
|---|---|---|---|
| Lock-File-Auflösung | `bootstrap` oder `adapter-out-db` | Absolut: direkt + Abbruch; Relativ: JAR-Dir → user.home → Abbruch; Parent-Dirs; Logging | #91 |
### Nicht geändert
- `pdf-umbenenner-domain` keine Änderungen, außer ggf. minimale Erweiterung
für #88 falls Attempt-Modell dort liegt (zulässig, keine Kernverarbeitungslogik)
- `pdf-umbenenner-adapter-in-cli` keine neuen Funktionen
- Headless-Verarbeitungslogik vollständig unberührt
- Kernverarbeitungslogik (PDF lesen → KI → umbenennen)
---
## Datenbankmigrationen
Flyway ist die einzige Schema-Evolutionsquelle.
### Potenzielles Migrationsskript (abhängig von Code-Analyse #88)
Vor der Implementierung von #88 dokumentiert Claude Code verbindlich,
ob ein Fehlerfeld bereits im Schema existiert (Fall A / B / C siehe #88).
**Nur bei Fall B oder C:**
```sql
-- Fehlerdetails in processing_attempt ergänzen
-- Versionsnummer = nächste freie Flyway-Version zum Zeitpunkt der Implementierung
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
```
- `failure_details`: nutzerverständliche, gekürzte Fehlerbeschreibung;
Begrenzung auf 1000 Zeichen **vor Persistierung im Adapter** erzwungen,
Kürzung mit „…"; kein SQL-`CHECK`-Constraint
- Bestehende Zeilen erhalten automatisch `NULL` kein Datenverlust
- Alte Einträge ohne Fehlerdetails zeigen `promptText`-Platzhalter in der GUI
---
## Definition of Done (V3.1 gesamt)
- [ ] Alle 11 Issues implementiert und einzeln getestet
- [ ] `mvn clean verify` grün (alle Module, kein `-DskipTests`)
- [ ] `mvn clean install -Drevision=3.1.0` Build ohne Fehler
- [ ] Manueller GUI-Produkttest durchgeführt (Green build ≠ fertige Software)
- [ ] Keine Enum-Rohnamen in der GUI sichtbar (alle 8 Statuswerte mit Displaytext)
- [ ] Alle fehlenden Tooltips vorhanden; TableColumn-Header via Column-Graphic-Pattern
- [ ] Dirty-Indikator Konfig-Tab: kein programmgesteuertes Feuern, Baseline-Snapshot korrekt
- [ ] Live-Filter: 300 ms Debounce, Generation-Counter, Auswahl nach Suche geleert
- [ ] Mehrfachauswahl: Strg+A nur bei Tabellenfokus; Schlüssel-Snapshot; Partial-Success-Dialog
- [ ] `FAILED_FINAL`/`FAILED_RETRYABLE`/`SKIPPED_FINAL_FAILURE`: Fehlerursache sichtbar (oder Platzhalter)
- [ ] Leere `ai_reasoning`: `promptText`-Platzhalter (kein echter Text)
- [ ] Aktionsbuttons ereignisgetrieben reaktiviert nach allen Terminierungsgründen
- [ ] #87 Code-Analyse: DB-Kontext-Eigentümer dokumentiert (Fall A oder B)
- [ ] #87: Atomarer Ablauf via Temp-Datei; Pfadvergleich normalisiert + case-insensitive
- [ ] #87: Aktive DB bleibt bei Fehler unverändert; DB-Busy-Sperre korrekt zurückgesetzt
- [ ] #87: Flyway auf neuesten Stand; Hinweismeldung nach Wechsel
- [ ] Strg+Mausrad-Zoom: Delta-Akkumulation, immer konsumiert bei Strg, 10%500%
- [ ] Lock-File: Absolut direkt; Relativ zweistufig; Parent-Dirs; Pfad geloggt
- [ ] Code-Kommentare auf Deutsch; Logging auf Deutsch
- [ ] JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
- [ ] `betrieb.md` und `gui-bedienanleitung.md` auf V3.1-Stand gebracht
- [ ] Freigabedokument `freigabe-v3_1.md` erstellt
---
## Abnahmekriterien je Feature
### #77 Fehlende Tooltips
- [ ] Vollständige Bestandsaufnahme: Liste aller Elemente ohne Tooltip erstellt
- [ ] Alle identifizierten Elemente haben Tooltips (Anzahl aus Bestandsaufnahme)
- [ ] TableColumn-Header: Column-Graphic mit Label+Tooltip, kein Skin-/Lookup-Hack
- [ ] Column-Graphic: Sortierung, Header-Breite und CSS nicht sichtbar verschlechtert
- [ ] Neue Konstanten ausschließlich in `GuiTooltipTexts`, keine hartcodierten Strings
### #80 Dirty-Indikator Konfig-Tab
- [ ] Tab-Titel `* Konfiguration` nur nach echter Nutzeränderung gegenüber Baseline-Snapshot
- [ ] Programmgesteuertes Laden setzt kein Dirty-Flag (`loadingInProgress`-Schutz)
- [ ] Tab-Titel `Konfiguration` nach Speichern
- [ ] Bestätigungsdialog bei Laden neuer Konfig mit Dirty State
- [ ] DB-Pfad-Wechsel via #87 setzt Konfig-Tab dirty
- [ ] UX identisch zum Prompt-Tab (Sternchen, Dialog, Reset)
### #81 Enum-Bezeichnungen
- [ ] Status-ComboBox: „Alle Status" als erster Eintrag (GUI-interner Null-Filter)
- [ ] Status-ComboBox: alle 8 Statuswerte als Displaytext
- [ ] Versuche-Tabelle: alle 8 Statuswerte als Displaytext
- [ ] DB-Queries intern weiterhin mit Enum-Namen
- [ ] Kein Enum-Rohname für Endnutzer sichtbar
### #82 Live-Filter
- [ ] Suche startet nach 300 ms Tipp-Pause automatisch
- [ ] Generation-Counter: veraltete Worker-Ergebnisse werden verworfen
- [ ] Such-Button / Enter: sofortige Suche, Debounce abgebrochen
- [ ] Auswahl nach neuen Suchergebnissen vollständig geleert
- [ ] Leeres Suchfeld zeigt alle Einträge
- [ ] Worker-Thread, UI via `Platform.runLater()`
### #83 KI-Begründung leer
- [ ] `textArea.setPromptText(...)` bei leerem/null `ai_reasoning`
- [ ] `textArea.setText("")` kein Platzhaltertext als echter Inhalt
- [ ] TextArea bleibt sichtbar
### #84 Buttons reaktivieren
- [ ] Aktionsbuttons während Lauf deaktiviert
- [ ] Reaktivierung ereignisgetrieben nach: Erfolg, Fehlerabbruch, Nutzerabbruch, Exception
- [ ] Keine manuellen Workarounds notwendig
### #86 Mehrfachauswahl
- [ ] `SelectionMode.MULTIPLE` aktiv
- [ ] Strg+A nur bei Tabellenfokus (kein Konflikt mit Suchfeld)
- [ ] Strg+Klick, Shift+Klick korrekt
- [ ] Detailbereich: „X Einträge ausgewählt." bei Mehrfachauswahl
- [ ] Schlüssel-Snapshot vor Worker-Thread-Start
- [ ] Bulk-Reset: Bestätigungsdialog + Partial-Success-Dialog
- [ ] Bulk-Delete: Bestätigungsdialog + Partial-Success-Dialog
- [ ] Aktionen während Lauf gesperrt
### #87 Neue Datenbank anlegen
- [ ] Code-Analyse: DB-Kontext-Eigentümer dokumentiert, Fall A oder B entschieden
- [ ] Menüpunkt vorhanden, nur außerhalb von Läufen aktiv
- [ ] Aktive DB über normalisierten Pfadvergleich (case-insensitive, toRealPath) erkannt
- [ ] Bestehende Fremddatei: Überschreiben-Bestätigung
- [ ] DB-Busy-Sperre während Anlage aktiv; nach Erfolg/Fehler zuverlässig zurückgesetzt
- [ ] Neue DB als Temp-Datei; Flyway auf neuesten Stand
- [ ] Verbindungstest: Verbindung öffnen, Flyway-History prüfen, Leseabfrage erfolgreich
- [ ] Move mit `ATOMIC_MOVE + REPLACE_EXISTING`; vorhandene Datei nicht vorher separat löschen
- [ ] Kein halb-atomarer Fallback bei nicht unterstützter Kombination
- [ ] Fehlerfall: Temp-Datei gelöscht, aktive DB unverändert, Fehlerdialog
- [ ] `ActiveDatabaseContextPort.switchActiveDatabase()` schaltet Referenz um
- [ ] Verlauf-Tab: „Noch keine Verarbeitungen vorhanden."
- [ ] Statuszeile aktualisiert DB-Pfad
- [ ] Konfig-Tab wechselt in Dirty-State
- [ ] Hinweismeldung: Konfiguration speichern nicht vergessen
### #88 Fehlerursache FAILED_FINAL
- [ ] Schema-/Code-Analyse: Fall A/B/C dokumentiert vor Implementierung
- [ ] Ggf. Flyway-Migration mit nächster freier Versionsnummer
- [ ] Sortierung für „letzter Versuch" gegen Schema verifiziert
- [ ] Detailbereich: `failure_details` bei `FAILED_FINAL`, `FAILED_RETRYABLE`, `SKIPPED_FINAL_FAILURE`
- [ ] NULL/leer: `promptText`-Platzhalter
- [ ] 1000-Zeichen-Grenze spätestens vor DB-Persistierung erzwungen, Kürzung mit „…"
- [ ] Keine rohen Provider-/Exception-Meldungen persistiert
### #91 Lock-File Pfad
- [ ] Absoluter Pfad: direkt verwendet, kein Fallback, Abbruch bei Fehler
- [ ] Relativer Pfad: erst JAR-Verzeichnis, dann `user.home`, dann Abbruch
- [ ] Parent-Verzeichnisse automatisch angelegt
- [ ] Absoluter Pfad beim Start geloggt (INFO)
- [ ] Gilt für GUI- und Headless-Start
### #32 Mausrad-Zoom
- [ ] Strg+Scroll: Event grundsätzlich konsumiert (kein paralleles Scrollen)
- [ ] Delta-Akkumulation für kleine Trackpad-Deltas
- [ ] Zoom 10%500%, ca. 10 % je Notch
- [ ] Ohne Strg: normales Scrollen
- [ ] Viewport-Mitte beim Zoom möglichst stabil
- [ ] Fit-to-Width-Modus verlassen nach manuellem Zoom
- [ ] Zoom-Reset bei neuem PDF (Fit-to-Width)
- [ ] Zoom-State konsistent mit Toolbar-Zoom-Buttons
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25 -6
View File
@@ -66,9 +66,9 @@ Fallback auf aktuelles Datum ist erlaubt, wenn kein belastbares Datum eindeutig
### 4.3 Titel
- maximal **20 Zeichen (Basistitel)**
- maximal **konfigurierbare Anzahl Zeichen (Basistitel, Default 60, gültiger Bereich 10..120)**
- verständlich und eindeutig
- keine Sonderzeichen außer Leerzeichen
- keine Sonderzeichen außer Leerzeichen, Bindestrichen, Punkten, Kommas und Ampersands
---
@@ -87,7 +87,7 @@ Bei Namenskonflikten:
Regel:
- 20 Zeichen gelten nur für den Basistitel
- die konfigurierte maximale Titellänge gilt nur für den Basistitel
- Suffix wird zusätzlich ergänzt
---
@@ -192,7 +192,7 @@ Ein Ergebnis ist korrekt, wenn:
- Format stimmt
- Datum korrekt ist
- Titel max. 20 Zeichen hat
- Titel die konfigurierte maximale Länge einhält
- Dubletten korrekt behandelt wurden
- Begründung vorhanden ist
- Ergebnis reproduzierbar ist
@@ -201,12 +201,31 @@ Ein Ergebnis ist korrekt, wenn:
## 14. Nicht-Ziele
- keine manuelle Nachbearbeitung
- keine Benutzerinteraktion
- kein manueller Verarbeitungslauf durch den Benutzer (die KI-Verarbeitungskette
läuft ausschließlich automatisiert)
- keine Inhaltsänderung von Dokumenten
---
## 14a. Manuelle Korrektur des Dateinamens nach automatischer Verarbeitung
Nach Abschluss eines automatisierten Verarbeitungslaufs kann der Benutzer den von der
KI vorgeschlagenen Dateinamen der Zieldatei **manuell korrigieren**.
Verbindliche Regeln:
- Die Korrektur ist **optional** und ersetzt keinen erneuten KI-Aufruf.
- Der geänderte Dateiname muss denselben Formatregeln genügen wie ein automatisch
erzeugter Name (`YYYY-MM-DD - Titel.pdf`, zulässige Sonderzeichen, Titellänge).
- Namenskonflikte im Zielordner werden durch Dubletten-Suffix aufgelöst
(analog zur automatischen Verarbeitung).
- Die Umbenennung ist **atomar**: entweder Dateisystem und Datenbank werden
konsistent aktualisiert, oder die Aktion wird vollständig zurückgerollt.
- Die Quelldatei bleibt unverändert.
- Ein manuell korrigierter Dateiname wird in der Versuchshistorie persistiert.
---
## 15. Qualitätsanforderungen
- deterministisches Verhalten
+13 -5
View File
@@ -55,8 +55,8 @@ YYYY-MM-DD - Titel(2).pdf
```
Dabei gilt:
- die **20 Zeichen** beziehen sich nur auf den **Basistitel**
- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen
- die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel**
- das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge
- die Quelldatei wird **nie** überschrieben oder verändert
---
@@ -133,8 +133,8 @@ Beispiel:
#### Adapter Out
Enthält technische Implementierungen der Outbound-Ports, insbesondere:
- Dateisystem
- PDFBox
- Dateisystem (inkl. `FilesystemTargetFileRenameAdapter` für atomare Zieldatei-Umbenennung)
- PDFBox (Textauslese sowie direktes Seitenrendering für die GUI-Vorschau via `PDFRenderer.renderImageWithDPI`)
- SQLite
- KI-HTTP-Clients (eine Implementierung je unterstütztem Provider, siehe Abschnitt 11)
- Properties-/Umgebungs-Konfiguration
@@ -204,12 +204,19 @@ Verbindlich zweckmäßige Outbound-Ports:
- `FingerprintPort`
- `ProcessedDocumentRepository`
- `AiNamingPort`
- `TargetFileRenamePort`
- `ConfigurationPort`
- `RunLockPort`
- `ClockPort`
Der `AiNamingPort` bleibt **provider-neutral**. Er kennt weder OpenAI- noch Anthropic-spezifische Typen, Header, URLs oder Antwortformate. Provider-spezifische Details (Endpunkt, Authentifizierung, Request-/Response-Format) leben ausschließlich in den jeweiligen Adapter-Out-Implementierungen.
Der `TargetFileRenamePort` kapselt die atomare Umbenennung einer bereits kopierten Zieldatei.
Er wird vom Use Case `ManualFileRenameUseCase` genutzt und ist durch
`FilesystemTargetFileRenameAdapter` implementiert. Der Port-Vertrag enthält keine
`Path`- oder NIO-Typen in öffentlichen Signaturen; er arbeitet ausschließlich mit
Domain-Typen und String-basierten Dateinamen.
### 6.3 Logging
Logging ist **kein fachlicher Port**. Logging ist technische Infrastruktur.
@@ -290,7 +297,7 @@ Der Titel muss technisch diese Regeln erfüllen:
- Deutsch
- verständlich
- eindeutig genug für den Dokumentkontext
- maximal **20 Zeichen** als Basistitel
- maximal die **konfigurierte Titellänge** als Basistitel (Default 60, gültiger Bereich 10..120)
- keine unzulässigen Windows-Dateinamenzeichen
- keine generischen Platzhalter wie z. B. `Dokument`, `Datei`, `Scan`, `PDF`
- Eigennamen bleiben unverändert
@@ -532,6 +539,7 @@ Verbindlich zweckmäßige Parameter:
- `max.retries.transient`
- `max.pages`
- `max.text.characters`
- `max.title.length`
- `prompt.template.file`
Pro unterstützter Provider-Familie existiert ein eigener Parameter-Namensraum mit zweckmäßig mindestens:
+1 -1
View File
@@ -4,7 +4,7 @@
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<version>${revision}</version>
</parent>
<artifactId>pdf-umbenenner-adapter-in-cli</artifactId>
<packaging>jar</packaging>
+41 -5
View File
@@ -5,7 +5,7 @@
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<version>${revision}</version>
</parent>
<artifactId>pdf-umbenenner-adapter-in-gui</artifactId>
<packaging>jar</packaging>
@@ -39,6 +39,31 @@
<artifactId>javafx-controls</artifactId>
<classifier>win</classifier>
</dependency>
<!-- JavaFX-Swing-Interop: wird für SwingFXUtils.toFXImage (BufferedImage -> FX Image) benötigt -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-swing</artifactId>
<version>21.0.2</version>
<classifier>win</classifier>
</dependency>
<!-- PDF-Vorschau: PDFBox für direktes Rendering einzelner Seiten in BufferedImages -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
</dependency>
<!-- JBIG2-Codec für PDF-Bilddecodierung -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>jbig2-imageio</artifactId>
<version>3.0.4</version>
</dependency>
<!-- JPEG2000-Codec für erweiterte PDF-Bilddecodierung -->
<dependency>
<groupId>com.github.jai-imageio</groupId>
<artifactId>jai-imageio-jpeg2000</artifactId>
<version>1.4.0</version>
</dependency>
<!-- Logging -->
<dependency>
@@ -47,6 +72,19 @@
</dependency>
<!-- Test dependencies -->
<!--
log4j-core on the test classpath provides the logging implementation for
tests that instantiate production classes using LogManager.getLogger.
Without it, Log4j2 falls back to SimpleLogger during test execution and
prints "Log4j2 could not find a logging implementation" at test start.
The production classpath is unaffected; log4j-core is supplied by the
bootstrap module in the shaded runtime JAR.
-->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -92,8 +130,8 @@
prism.order=sw enables software rendering (no GPU required);
prism.text=t2k selects the T2K text rasterizer (headless-safe);
java.awt.headless=true signals headless mode to AWT/Swing interop layers.
The add-opens args are required for JavaFX internal access patterns used
by Monocle and the Platform.startup API in Java 21 module context.
Note: module-opening arguments for javafx.graphics are no longer required.
Modern JavaFX (21.x) with Monocle on Java 21 works without explicit module opening.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -106,8 +144,6 @@
-Dprism.order=sw
-Dprism.text=t2k
-Djava.awt.headless=true
--add-opens=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED
--add-opens=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED
</argLine>
</configuration>
</plugin>
@@ -0,0 +1,33 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.List;
import javafx.stage.FileChooser;
/**
* Funktionales Interface fuer den Datei-Auswaehldialog der GUI.
* <p>
* Kapselt die Abhaengigkeit zum nativen {@link FileChooser} in einem
* injizierbaren Hook, der in Tests durch eine einfache Lambda-Implementierung
* ersetzt werden kann. Die Standardimplementierung oeffnet einen echten
* nativen Datei-Dialog; Test-Stubs koennen einen festen Pfad zurueckgeben
* oder {@code null} simulieren (Abbrechen).
* <p>
* Im Gegensatz zur frueheren {@code BiFunction}-Variante nimmt dieser Hook
* auch die Liste der {@link FileChooser.ExtensionFilter} entgegen, damit der
* native Dialog die Filter tatsaechlich anwenden kann.
*/
@FunctionalInterface
interface FilePickerDialog {
/**
* Oeffnet den Datei-Auswaehldialog und gibt den ausgewaehlten absoluten
* Pfad zurueck.
*
* @param title der Titel des Dialogs
* @param initialPath der Anfangspfad als Hinweis; darf leer oder {@code null} sein
* @param filters Liste der Dateitypfilter; darf leer sein, aber nicht {@code null}
* @return der ausgewaehlte absolute Pfad als String, oder {@code null} wenn abgebrochen
*/
String pick(String title, String initialPath, List<FileChooser.ExtensionFilter> filters);
}
@@ -0,0 +1,66 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.nio.file.Path;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
/**
* Callback invoked by the workspace on a background thread after a configuration file
* has been successfully loaded from disk.
* <p>
* Bootstrap supplies an implementation that builds the application run context
* (migrate load validate schema-init sequence) and, on success, also initialises
* the automatic scheduler. The workspace calls this initializer inside the same
* background submit that loads the editor state, so the JavaFX Application Thread is
* never blocked.
* <p>
* In isolated GUI tests a {@link #noOp() no-op} implementation can be used so that no
* Bootstrap wiring is required.
*/
@FunctionalInterface
public interface GuiApplicationContextInitializer {
/**
* Attempts to initialise the application run context for the supplied configuration file.
* <p>
* If context initialisation succeeds and the configuration enables the scheduler, the
* scheduler is also wired and its use case is returned in the result. The caller is
* responsible for handing the scheduler use case to the scheduler tab on the JavaFX
* Application Thread via {@code Platform.runLater}.
* <p>
* This method must be called on a background worker thread, not on the JavaFX Application
* Thread.
*
* @param configFilePath path to the {@code .properties} configuration file; must exist on disk
* @return the result of the initialisation attempt; never {@code null}
*/
InitResult initialize(Path configFilePath);
/**
* Returns a no-op initializer that always reports success and no scheduler.
* <p>
* Suitable for GUI tests and startup paths where no Bootstrap wiring is available.
*
* @return no-op initializer; never {@code null}
*/
static GuiApplicationContextInitializer noOp() {
return configFilePath -> new InitResult(Optional.empty(), Optional.empty());
}
/**
* Result of a context initialisation attempt.
*
* @param contextError empty on success; a human-readable German error message
* when initialisation failed the GUI remains functional
* but falls back to per-run initialisation for batch runs;
* must not be {@code null}
* @param schedulerControlUseCase the scheduler use case when the configuration enables the
* scheduler and initialisation succeeded; empty otherwise;
* must not be {@code null}
*/
record InitResult(
Optional<String> contextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase) {
}
}
@@ -0,0 +1,41 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase.CreateNewDatabaseResult;
/**
* GUI-internes Bridge-Interface zwischen dem Workspace und dem
* {@link de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase}.
* <p>
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Es ist eine modul-interne Brücke, über die Bootstrap die DB-Anlage- und Wechsellogik
* für die GUI bereitstellt, ohne dass der GUI-Adapter direkt auf den Use-Case oder die
* darunterliegenden Outbound-Ports zugreift.
* <p>
* <strong>Threading:</strong> Implementierungen dürfen blockierende Operationen
* ausführen (Flyway-Migration, Verbindungstest, atomares Verschieben einer Datei).
* Sie müssen daher von einem Hintergrund-Worker-Thread aufgerufen werden. Der Aufruf
* blockiert, bis das Ergebnis vollständig vorliegt.
*/
@FunctionalInterface
public interface GuiCreateNewDatabasePort {
/**
* Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und
* stellt die aktive Datenbankreferenz auf diese Datei um.
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei,
* oder {@code null}, wenn (noch) keine Konfiguration
* geladen ist. Die Bootstrap-Implementierung leitet
* daraus den Pfad der aktuell aktiven SQLite-Datei ab,
* sofern noch kein Override vom
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}
* gesetzt ist.
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht
* {@code null} sein
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler;
* nie {@code null}
*/
CreateNewDatabaseResult createNewDatabase(Path configFilePath, Path targetFile);
}
@@ -5,6 +5,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import org.apache.logging.log4j.LogManager;
@@ -35,6 +36,13 @@ import javafx.application.Platform;
* completed retrieval attempt, so later GUI layers can display the result.</li>
* </ul>
* <p>
* Parallele Abrufanfragen (z.&nbsp;B. durch schnellen Provider-Wechsel oder mehrfaches Klicken
* auf Modelle neu laden") werden durch einen Generationszähler entschärft: Jede neue Anfrage
* erhöht den Zähler. Wenn das Ergebnis eines Hintergrund-Threads auf dem JavaFX-Thread
* verarbeitet wird, prüft der Coordinator, ob die Generationsnummer noch aktuell ist. Veraltete
* Ergebnisse (aus einer früheren Anfrage) werden verworfen, sodass stets nur das Ergebnis der
* jüngsten Anfrage in die Meldungsliste und die Feldcontainer geschrieben wird.
* <p>
* The worker thread factory is injectable so tests can supply a synchronous or latch-guarded
* executor without spinning a real OS thread.
* <p>
@@ -43,6 +51,10 @@ import javafx.application.Platform;
* {@code Platform.runLater}.
*/
public final class GuiModelCatalogCoordinator {
private static final String LOG_MODEL_FETCH_FMT = "GUI-Modellabruf: {} (Provider: {})";
private static final String OPERATION_MODELLABRUF = "Modellabruf";
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
@@ -62,6 +74,14 @@ public final class GuiModelCatalogCoordinator {
private final Map<AiProviderFamily, GuiModelFieldContainer> fieldContainers =
new ConcurrentHashMap<>();
/**
* Generationszähler zur Erkennung veralteter Abruf-Ergebnisse.
* Wird bei jeder neuen Anfrage in {@link #triggerModelRetrieval} atomar erhöht.
* Hintergrund-Threads erfassen die Generation beim Start; auf dem JavaFX-Thread wird
* das Ergebnis verworfen, wenn die gespeicherte Generation nicht mehr aktuell ist.
*/
private final AtomicLong retrievalGeneration = new AtomicLong(0);
/**
* Consumer that delivers the retrieval result. In production this wraps the call in
* {@code Platform.runLater}. In tests it can be replaced with a direct call so the result
@@ -144,12 +164,23 @@ public final class GuiModelCatalogCoordinator {
// Build the request from the current editor state.
ModelCatalogRequest request = buildRequest(family, providerState);
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet.",
family.getIdentifier());
// Generationsnummer erhöhen laufende Hintergrund-Threads mit einer älteren
// Generationsnummer verwerfen ihr Ergebnis, sobald sie auf dem FX-Thread ankommen.
long currentGeneration = retrievalGeneration.incrementAndGet();
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet (Generation {}).",
family.getIdentifier(), currentGeneration);
Runnable task = () -> {
ModelCatalogResult result = modelCatalogPort.fetchAvailableModels(request);
resultDelivery.accept(() -> {
// Veraltetes Ergebnis verwerfen, wenn inzwischen eine neuere Anfrage gestartet wurde.
if (retrievalGeneration.get() != currentGeneration) {
LOG.debug("GUI-Modellabruf: Ergebnis für Provider '{}' verworfen"
+ " (Generation {} ist nicht mehr aktuell).",
family.getIdentifier(), currentGeneration);
return;
}
applyResult(family, container, result, previousManualValue);
postResultCallback.run();
});
@@ -176,7 +207,7 @@ public final class GuiModelCatalogCoordinator {
String previousManualValue) {
// Remove any previous message entries from an earlier retrieval so messages do not
// accumulate across repeated triggers of the same retrieval action.
pendingMessages.removeIf(msg -> "Modellabruf".equals(msg.source().orElse("")));
pendingMessages.removeIf(msg -> OPERATION_MODELLABRUF.equals(msg.source().orElse("")));
String displayName = displayNameFor(family);
@@ -186,28 +217,28 @@ public final class GuiModelCatalogCoordinator {
container.applyModelList(models, previousManualValue);
String message = "Modellliste für " + displayName + " geladen ("
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, "Modellabruf"));
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, OPERATION_MODELLABRUF));
LOG.info(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
}
case ModelCatalogResult.EmptyList emptyList -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Provider " + displayName
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf"));
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, OPERATION_MODELLABRUF));
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
}
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
+ ". Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, "Modellabruf"));
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, OPERATION_MODELLABRUF));
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
}
case ModelCatalogResult.TechnicalFailure failure -> {
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
+ "). Manuelle Eingabe aktiv.";
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, "Modellabruf"));
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, OPERATION_MODELLABRUF));
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
message, failure.errorDetail(), family.getIdentifier());
}
@@ -0,0 +1,64 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
/**
* GUI-internes Bridge-Interface zwischen dem Prompt-Editor-Tab und dem zugehörigen
* Use-Case in der Application-Schicht.
* <p>
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Es ist eine modul-interne Brücke, über die Bootstrap die vom Use-Case bereitgestellte
* Funktionalität in den GUI-Adapter einschleust, ohne dass der GUI-Adapter direkt auf
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PromptPort} oder das Dateisystem
* zugreift.
* <p>
* <strong>Verantwortung:</strong>
* <ul>
* <li>Prompt-Inhalt für die Anzeige im Editor laden.</li>
* <li>Bearbeiteten Inhalt atomar speichern.</li>
* <li>Standard-Prompt-Datei anlegen, wenn noch keine vorhanden ist.</li>
* </ul>
* <p>
* Alle Implementierungen dieses Interfaces liegen in {@code pdf-umbenenner-bootstrap}.
* Das GUI-Modul kennt ausschließlich den Interface-Typ.
*/
public interface GuiPromptEditorPort {
/**
* Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Quelle.
* <p>
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
*
* @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg,
* oder einem klassifizierten Fehler; nie {@code null}
*/
PromptLoadingResult loadCurrentPrompt();
/**
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
* <p>
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
*
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @throws NullPointerException wenn {@code content} null ist
*/
PromptSaveResult save(String content);
/**
* Legt eine Standard-Prompt-Datei an, falls noch keine vorhanden ist.
* <p>
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
*
* @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein
* @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null}
* @throws NullPointerException wenn {@code suggestion} null ist
*/
CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion);
}
@@ -0,0 +1,22 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
/**
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort} erzeugt.
* <p>
* Wird vom {@link GuiConfigurationEditorWorkspace} genutzt, um nach einem Konfigurations-Laden
* oder -Speichern einen neuen Port für den {@link GuiPromptEditorTab} zu erstellen, ohne dass
* der GUI-Adapter direkt von Bootstrap-internen Klassen abhängen muss.
* <p>
* Alle Implementierungen liegen in {@code pdf-umbenenner-bootstrap}.
*/
@FunctionalInterface
public interface GuiPromptEditorPortFactory {
/**
* Erzeugt einen {@link GuiPromptEditorPort} für den angegebenen Prompt-Dateipfad.
*
* @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
* @return vollständig verdrahteter Port; nie {@code null}
*/
GuiPromptEditorPort create(String promptFilePath);
}
@@ -0,0 +1,413 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TextArea;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.scene.text.Font;
/**
* Tab Prompt" im Hauptfenster des GUI-Adapters.
* <p>
* Ermöglicht das Lesen, Bearbeiten und Speichern der konfigurierten KI-Prompt-Datei
* direkt aus der Oberfläche heraus, ohne einen externen Editor öffnen zu müssen.
* <p>
* <strong>Verhalten:</strong>
* <ul>
* <li>Beim Öffnen des Tabs wird der aktuelle Prompt-Inhalt auf einem Worker-Thread geladen.</li>
* <li>Bearbeitungen erzeugen einen Dirty-State; der Tab-Titel erhält einen Asterisk.</li>
* <li>Speichern" schreibt den Inhalt atomar via {@link GuiPromptEditorPort}.</li>
* <li>Auf Standard zurücksetzen" befüllt die TextArea mit dem Default-Template,
* ohne zu speichern.</li>
* <li>Bei fehlendem Prompt wird ein Hinweis und ein Standard-Prompt erstellen"-Button
* angezeigt.</li>
* <li>Tab-Wechsel oder Schließen mit Dirty-State löst einen Bestätigungsdialog aus.</li>
* </ul>
* <p>
* <strong>Threading:</strong> Alle blockierenden Operationen (Laden, Speichern,
* Prompt-Datei anlegen) laufen auf einem Worker-Thread. UI-Aktualisierungen erfolgen
* ausschließlich via {@code Platform.runLater}.
*/
public class GuiPromptEditorTab {
private static final Logger LOG = LogManager.getLogger(GuiPromptEditorTab.class);
private static final String TAB_TITLE = "Prompt";
private static final String TAB_TITLE_DIRTY = "Prompt *";
private GuiPromptEditorPort promptEditorPort;
/** Konfigurierter Prompt-Dateipfad wird für CreatePromptFile-Vorschläge benötigt. */
private String configuredPromptPath;
/** Konfigurierte maximale Titellänge für den Default-Prompt-Inhalt. */
private int maxTitleLength;
// Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
/** Erzeugt Worker-Threads für blockierende Operationen. */
Function<Runnable, Thread> threadFactory;
/** Übergibt UI-Updates an den JavaFX Application Thread. */
Consumer<Runnable> fxDispatcher;
private final Tab tab = new Tab(TAB_TITLE);
private final TextArea textArea = new TextArea();
private final Label statusLabel = new Label();
private final Button saveButton = new Button("Speichern");
private final Button resetButton = new Button("Auf Standard zurücksetzen");
private final Button createDefaultButton = new Button("Standard-Prompt erstellen");
/** Zeigt an, ob der aktuelle Inhalt der TextArea vom geladenen Stand abweicht. */
private boolean dirty = false;
/** Zuletzt aus der Datei geladener Inhalt (Baseline). */
private String loadedContent = null;
/**
* Erstellt den Prompt-Editor-Tab.
*
* @param promptEditorPort Bridge-Port zum Use-Case; darf nicht {@code null} sein
* @param configuredPromptPath konfigurierter Pfad zur Prompt-Datei (für CreatePromptFile);
* darf nicht {@code null} sein
* @param maxTitleLength konfigurierte maximale Titellänge für den Default-Prompt
* @throws NullPointerException wenn {@code promptEditorPort} oder {@code configuredPromptPath} null ist
*/
public GuiPromptEditorTab(GuiPromptEditorPort promptEditorPort,
String configuredPromptPath,
int maxTitleLength) {
this.promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
this.configuredPromptPath = Objects.requireNonNull(configuredPromptPath, "configuredPromptPath must not be null");
this.maxTitleLength = maxTitleLength;
// Standard-Implementierungen für den Produktionsbetrieb
this.threadFactory = runnable -> {
Thread t = new Thread(runnable, "gui-prompt-editor");
t.setDaemon(true);
return t;
};
this.fxDispatcher = Platform::runLater;
buildTab();
}
/**
* Liefert das JavaFX-Tab-Objekt, das dem TabPane hinzugefügt werden kann.
*
* @return das Tab; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Gibt an, ob der Prompt-Editor ungespeicherte Änderungen enthält.
*
* @return {@code true}, wenn Dirty-State aktiv ist
*/
public boolean hasDirtyContent() {
return dirty;
}
/**
* Aktualisiert den Tab auf eine neue Konfiguration.
* <p>
* Setzt Port, Prompt-Dateipfad und maximale Titellänge auf die neuen Werte.
* Der bisherige Lade-Baseline wird verworfen und der Dirty-State zurückgesetzt.
* Ist der Tab zum Zeitpunkt des Aufrufs sichtbar, wird ein erneutes Laden sofort
* ausgelöst; andernfalls erfolgt das Laden beim nächsten Öffnen des Tabs.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param newPort neuer Port für Prompt-Operationen; darf nicht {@code null} sein
* @param newPromptPath neuer konfigurierter Prompt-Dateipfad; darf nicht {@code null} sein
* @param newMaxTitleLength neue konfigurierte maximale Titellänge
*/
public void notifyConfigurationChanged(GuiPromptEditorPort newPort,
String newPromptPath,
int newMaxTitleLength) {
this.promptEditorPort = Objects.requireNonNull(newPort, "newPort must not be null");
this.configuredPromptPath = Objects.requireNonNull(newPromptPath, "newPromptPath must not be null");
this.maxTitleLength = newMaxTitleLength;
this.loadedContent = null;
this.dirty = false;
this.tab.setText(TAB_TITLE);
this.saveButton.setDisable(true);
if (tab.isSelected()) {
loadPromptAsync();
}
}
/**
* Verwirft alle ungespeicherten Änderungen und setzt den Tab in den Lade-Bereitschaftszustand.
* <p>
* Setzt Dirty-State und Tab-Titel zurück. Ist der Tab zum Zeitpunkt des Aufrufs sichtbar,
* wird der Prompt-Inhalt sofort neu geladen; andernfalls erfolgt das Laden beim nächsten
* Öffnen des Tabs (gesteuert durch den Tab-Selektions-Listener).
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void discardChanges() {
this.loadedContent = null;
this.dirty = false;
this.tab.setText(TAB_TITLE);
this.saveButton.setDisable(true);
if (tab.isSelected()) {
loadPromptAsync();
}
}
/**
* Zeigt einen Bestätigungsdialog, wenn ungespeicherte Änderungen vorhanden sind.
* Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen.
*
* @return {@code true} zum Verwerfen, {@code false} zum Abbrechen
*/
public boolean confirmDiscardIfDirty() {
if (!dirty) {
return true;
}
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
alert.setTitle("Ungespeicherte Änderungen");
alert.setHeaderText("Der Prompt-Editor enthält ungespeicherte Änderungen.");
alert.setContentText("Möchten Sie die Änderungen verwerfen?");
alert.getButtonTypes().setAll(
new ButtonType("Verwerfen"),
ButtonType.CANCEL);
Optional<ButtonType> result = alert.showAndWait();
return result.isPresent() && result.get().getText().equals("Verwerfen");
}
/**
* Lädt den aktuellen Prompt-Inhalt auf einem Worker-Thread und zeigt ihn in der TextArea an.
* <p>
* Muss vom JavaFX Application Thread aufgerufen werden. Die eigentliche I/O-Operation
* läuft auf einem Hintergrund-Thread; UI-Updates erfolgen via {@code fxDispatcher}.
*/
public void loadPromptAsync() {
setStatus("Lade Prompt-Datei ...");
saveButton.setDisable(true);
resetButton.setDisable(true);
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
Thread worker = threadFactory.apply(() -> {
var result = promptEditorPort.loadCurrentPrompt();
fxDispatcher.accept(() -> applyLoadResult(result));
});
worker.start();
}
// -------------------------------------------------------------------------
// Private Aufbau
// -------------------------------------------------------------------------
private void buildTab() {
tab.setClosable(false);
// TextArea monospace Font für bessere Lesbarkeit
textArea.setWrapText(true);
textArea.setFont(Font.font("Monospace", 13));
textArea.setPrefRowCount(20);
textArea.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_TEXTAREA));
VBox.setVgrow(textArea, Priority.ALWAYS);
// Dirty-State-Tracking
textArea.textProperty().addListener((obs, oldVal, newVal) -> {
if (loadedContent != null) {
boolean nowDirty = !newVal.equals(loadedContent);
if (nowDirty != dirty) {
dirty = nowDirty;
tab.setText(dirty ? TAB_TITLE_DIRTY : TAB_TITLE);
}
}
});
// Status-Label
statusLabel.setWrapText(true);
statusLabel.setStyle("-fx-text-fill: #555555;");
// Buttons verdrahten
saveButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_SPEICHERN));
saveButton.setOnAction(e -> requestSave());
resetButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_ZURUECKSETZEN));
resetButton.setOnAction(e -> resetToDefault());
createDefaultButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_STANDARD_ANLEGEN));
createDefaultButton.setOnAction(e -> requestCreateDefault());
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
HBox buttonBar = new HBox(8, saveButton, resetButton, createDefaultButton);
buttonBar.setAlignment(Pos.CENTER_LEFT);
buttonBar.setPadding(new Insets(6, 0, 0, 0));
VBox content = new VBox(6, textArea, statusLabel, buttonBar);
content.setPadding(new Insets(12));
VBox.setVgrow(textArea, Priority.ALWAYS);
BorderPane root = new BorderPane(content);
tab.setContent(root);
// Beim Öffnen des Tabs laden (falls Konfiguration bereits vorhanden)
tab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
if (Boolean.TRUE.equals(isSelected) && loadedContent == null) {
loadPromptAsync();
}
});
}
private void applyLoadResult(de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult result) {
if (result instanceof PromptLoadingSuccess success) {
loadedContent = success.promptContent();
textArea.setText(loadedContent);
textArea.setEditable(true);
saveButton.setDisable(false);
resetButton.setDisable(false);
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
setStatus("Prompt-Datei geladen. Identifikator: " + success.promptIdentifier().identifier());
dirty = false;
tab.setText(TAB_TITLE);
LOG.info("Prompt-Editor: Prompt-Datei erfolgreich geladen (Identifikator: {}).",
success.promptIdentifier().identifier());
} else if (result instanceof PromptLoadingFailure failure) {
boolean fileNotFound = "FILE_NOT_FOUND".equals(failure.failureReason());
if (fileNotFound) {
// Datei fehlt Hinweis und Anlegen-Button anzeigen
loadedContent = null;
textArea.setEditable(false);
textArea.clear();
saveButton.setDisable(true);
resetButton.setDisable(false);
createDefaultButton.setVisible(true);
createDefaultButton.setManaged(true);
setStatus("Keine Prompt-Datei vorhanden. Legen Sie eine Standard-Datei an oder "
+ "konfigurieren Sie den Pfad im Konfigurationstab.");
LOG.info("Prompt-Editor: Keine Prompt-Datei am konfigurierten Pfad vorhanden.");
} else {
// Anderer Fehler (I/O, leer usw.)
loadedContent = null;
textArea.setEditable(false);
textArea.clear();
saveButton.setDisable(true);
resetButton.setDisable(false);
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
setStatus("Fehler beim Laden der Prompt-Datei: " + failure.failureMessage());
LOG.warn("Prompt-Editor: Laden fehlgeschlagen ({}): {}",
failure.failureReason(), failure.failureMessage());
}
}
}
private void requestSave() {
String currentText = textArea.getText();
// Leerer Prompt: Bestätigungsdialog
if (currentText.trim().isEmpty()) {
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
confirm.setTitle("Leerer Prompt");
confirm.setHeaderText("Der Prompt ist leer.");
confirm.setContentText("Wirklich eine leere Prompt-Datei speichern?");
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
Optional<ButtonType> choice = confirm.showAndWait();
if (choice.isEmpty() || choice.get() != ButtonType.OK) {
return;
}
}
setStatus("Speichere ...");
saveButton.setDisable(true);
Thread worker = threadFactory.apply(() -> {
PromptSaveResult result = promptEditorPort.save(currentText);
fxDispatcher.accept(() -> applySaveResult(result, currentText));
});
worker.start();
}
private void applySaveResult(PromptSaveResult result, String savedContent) {
saveButton.setDisable(false);
if (result instanceof PromptSaveResult.Saved saved) {
loadedContent = savedContent;
dirty = false;
tab.setText(TAB_TITLE);
setStatus("Prompt-Datei gespeichert: " + saved.absolutePath());
textArea.setEditable(true);
LOG.info("Prompt-Editor: Prompt-Datei gespeichert unter {}.", saved.absolutePath());
} else if (result instanceof PromptSaveResult.TargetDirectoryMissing missing) {
setStatus("Fehler: " + missing.message());
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen Ordner fehlt: {}", missing.message());
} else if (result instanceof PromptSaveResult.WriteFailed failed) {
setStatus("Fehler beim Schreiben: " + failed.message());
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen Schreibfehler: {}", failed.message());
} else if (result instanceof PromptSaveResult.AtomicMoveFailed atomicFailed) {
setStatus("Fehler: Atomares Speichern fehlgeschlagen (kein Fallback). " + atomicFailed.message());
LOG.warn("Prompt-Editor: Atomares Verschieben fehlgeschlagen: {}", atomicFailed.message());
}
}
void resetToDefault() {
String defaultContent = de.gecheckt.pdf.umbenenner.application.validation
.technicaltest.DefaultPromptTemplate.defaultContent(maxTitleLength);
textArea.setText(defaultContent);
textArea.setEditable(true);
saveButton.setDisable(false);
setStatus("Standard-Prompt-Inhalt in den Editor geladen (noch nicht gespeichert).");
LOG.info("Prompt-Editor: Standard-Prompt-Inhalt in TextArea geladen (nicht gespeichert).");
}
private void requestCreateDefault() {
createDefaultButton.setDisable(true);
setStatus("Lege Standard-Prompt-Datei an ...");
CorrectionSuggestion.CreatePromptFile suggestion = new CorrectionSuggestion.CreatePromptFile(
configuredPromptPath,
"Standard-Prompt-Datei anlegen",
maxTitleLength);
Thread worker = threadFactory.apply(() -> {
CorrectionOutcome outcome = promptEditorPort.createDefaultPromptIfMissing(suggestion);
fxDispatcher.accept(() -> applyCreateDefaultResult(outcome));
});
worker.start();
}
private void applyCreateDefaultResult(CorrectionOutcome outcome) {
createDefaultButton.setDisable(false);
if (outcome instanceof CorrectionOutcome.Applied applied) {
setStatus(applied.message() + " Lade Inhalt ...");
LOG.info("Prompt-Editor: Standard-Prompt-Datei angelegt. Lade neu.");
// Inhalt sofort neu laden
loadPromptAsync();
} else if (outcome instanceof CorrectionOutcome.Failed failed) {
setStatus("Fehler beim Anlegen der Standard-Prompt-Datei: " + failed.errorMessage());
LOG.warn("Prompt-Editor: Anlegen der Standard-Prompt-Datei fehlgeschlagen: {}", failed.errorMessage());
} else if (outcome instanceof CorrectionOutcome.NotAttempted notAttempted) {
setStatus("Aktion nicht verfügbar: " + notAttempted.reason());
LOG.warn("Prompt-Editor: Anlegen nicht versucht: {}", notAttempted.reason());
}
}
private void setStatus(String message) {
statusLabel.setText(message);
}
}
@@ -0,0 +1,474 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Separator;
import javafx.scene.control.Tab;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
/**
* Fünfter Haupt-Tab des JavaFX-Editorfensters: die Scheduler-Steuerungsansicht.
* <p>
* Zeigt den aktuellen Zustand des automatischen Schedulers und erlaubt dessen
* Steuerung über {@link SchedulerControlUseCase}. Der Tab-Inhalt wird im Sekundentakt
* durch {@link #updateStatus(SchedulerStatus)} aktualisiert, das von der zentralen
* {@link GuiStatusRefreshTimeline} aufgerufen wird.
*
* <h2>Bereiche</h2>
* <ul>
* <li><strong>Scheduler-Steuerung</strong>: Status-Anzeige ( Aktiv / Gestoppt),
* Start-/Stopp-Schaltflächen, Countdown bis zum nächsten Lauf,
* Letzter-Lauf-Info, Fehlermeldung und Intervall-Konfiguration.</li>
* </ul>
*
* <h2>Threading</h2>
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
* werden. Start-, Stopp- und Speichern-Aktionen werden auf einem dedizierten
* Hintergrund-Worker-Thread ({@code gui-scheduler-control}) ausgeführt.
*/
public final class GuiSchedulerTab {
private static final String HEADER_LABEL_STYLE = "-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #7f8c8d;";
private static final Logger LOG = LogManager.getLogger(GuiSchedulerTab.class);
private static final String TAB_TITLE = "Scheduler";
/** Mindestwert für das konfigurierbare Ausführungsintervall. */
static final int MIN_INTERVAL_SECONDS = 30;
private static final DateTimeFormatter TIME_FORMATTER =
DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault());
private final Tab tab = new Tab(TAB_TITLE);
// Not final: may be updated via onSchedulerAvailable after the tab was created without a use
// case (e.g., when auto-load initialises the scheduler after the workspace was already built).
// Declared volatile so worker-thread reads (executeStart/Stop) see the write from the FX thread.
private volatile Optional<SchedulerControlUseCase> schedulerUseCase;
private final Supplier<Boolean> isConfigDirty;
// -------------------------------------------------------------------------
// Bereich 1: Scheduler-Steuerung
// -------------------------------------------------------------------------
private final Label statusLabel = new Label("○ Gestoppt");
private final Button startButton = new Button("Scheduler starten");
private final Button stopButton = new Button("Scheduler stoppen");
private final Label nextTickLabel = new Label();
private final Label lastRunLabel = new Label("Noch kein Lauf in dieser Sitzung.");
private final Label sessionTotalsLabel = new Label();
private final Label lastErrorLabel = new Label();
private final TextField intervalField = new TextField();
private final Label intervalValidationLabel = new Label();
private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "gui-scheduler-control");
t.setDaemon(true);
return t;
});
/**
* Erstellt einen neuen Scheduler-Tab.
*
* @param schedulerUseCase optionaler Use Case zur Scheduler-Steuerung;
* {@code null} wird als leer behandelt
* @param isConfigDirty Supplier der {@code true} zurückgibt wenn der
* Konfigurationseditor ungespeicherte Änderungen hat;
* {@code null} wird als immer {@code false} behandelt
*/
public GuiSchedulerTab(
Optional<SchedulerControlUseCase> schedulerUseCase,
Supplier<Boolean> isConfigDirty) {
this.schedulerUseCase = Objects.requireNonNullElse(schedulerUseCase, Optional.empty());
this.isConfigDirty = isConfigDirty != null ? isConfigDirty : () -> false;
tab.setClosable(false);
buildUi();
applyInitialState();
}
/**
* Liefert den JavaFX-Tab-Knoten für den Einhang in das {@code TabPane}.
*
* @return Tab-Knoten; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Macht den Scheduler-Use-Case für diesen Tab verfügbar, nachdem er nach einem
* erfolgreichen Datei-Öffnen initialisiert wurde.
* <p>
* Wird vom Workspace auf dem JavaFX Application Thread aufgerufen, nachdem der
* {@link GuiApplicationContextInitializer} auf einem Hintergrund-Thread einen
* {@link SchedulerControlUseCase} geliefert hat. Hat keine Wirkung, wenn bereits
* ein Use Case vorhanden ist.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param useCase der neu initialisierte Use Case; darf nicht {@code null} sein
*/
public void onSchedulerAvailable(SchedulerControlUseCase useCase) {
if (schedulerUseCase.isPresent()) {
return;
}
schedulerUseCase = Optional.of(useCase);
intervalField.setText(String.valueOf(useCase.getIntervalSeconds()));
intervalField.setEditable(true);
intervalField.setDisable(false);
startButton.setDisable(false);
startButton.setTooltip(null);
}
/**
* Gibt den aktuell verdrahteten Scheduler-Use-Case zurück.
* <p>
* Wird von der zentralen Status-Refresh-Timeline benötigt, weil der Use Case
* erst nach erfolgreichem Datei-Öffnen verfügbar wird (z. B. durch Auto-Load
* der zuletzt geladenen Konfiguration) und damit nicht im
* unveränderlichen {@code GuiStartupContext} steht.
*
* @return aktueller Use Case oder {@code Optional.empty()} wenn keiner verdrahtet ist
*/
public Optional<SchedulerControlUseCase> currentSchedulerUseCase() {
return schedulerUseCase;
}
/**
* Aktualisiert alle Tab-Elemente anhand des aktuellen Scheduler-Status.
* <p>
* Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem
* JavaFX Application Thread aufgerufen. Implementiert alle in der Spezifikation
* definierten Button-Zustände, Label-Texte und Sichtbarkeitsregeln.
*
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
*/
public void updateStatus(SchedulerStatus status) {
updateStatusLabel(status);
updateButtons(status);
updateNextTickLabel(status);
updateLastRunLabel(status);
updateSessionTotalsLabel(status);
updateLastErrorLabel(status);
updateIntervalFieldEditability(status);
}
// -------------------------------------------------------------------------
// UI-Aufbau
// -------------------------------------------------------------------------
private void buildUi() {
VBox controlArea = buildControlArea();
tab.setContent(controlArea);
wireActions();
}
private VBox buildControlArea() {
statusLabel.setStyle(HEADER_LABEL_STYLE);
stopButton.setDisable(true);
HBox buttonBox = new HBox(10, startButton, stopButton);
nextTickLabel.setVisible(false);
nextTickLabel.setManaged(false);
lastRunLabel.setWrapText(true);
sessionTotalsLabel.setWrapText(true);
sessionTotalsLabel.setStyle("-fx-text-fill: #7f8c8d;");
sessionTotalsLabel.setVisible(false);
sessionTotalsLabel.setManaged(false);
lastErrorLabel.setStyle("-fx-text-fill: #c0392b;");
lastErrorLabel.setWrapText(true);
lastErrorLabel.setVisible(false);
lastErrorLabel.setManaged(false);
Label intervalLabel = new Label("Intervall (Sekunden):");
intervalField.setPrefColumnCount(10);
HBox intervalBox = new HBox(10, intervalLabel, intervalField);
intervalBox.setAlignment(Pos.CENTER_LEFT);
intervalValidationLabel.setStyle("-fx-text-fill: #c0392b; -fx-font-size: 11px;");
intervalValidationLabel.setWrapText(true);
intervalValidationLabel.setVisible(false);
intervalValidationLabel.setManaged(false);
VBox controlArea = new VBox(12,
statusLabel,
buttonBox,
nextTickLabel,
lastRunLabel,
sessionTotalsLabel,
lastErrorLabel,
new Separator(),
intervalBox,
intervalValidationLabel);
controlArea.setPadding(new Insets(16));
return controlArea;
}
private void wireActions() {
startButton.setOnAction(e -> executeStart());
stopButton.setOnAction(e -> executeStop());
intervalField.focusedProperty().addListener((obs, wasFocused, focused) -> {
if (!focused) {
validateAndSaveInterval();
}
});
}
private void applyInitialState() {
if (schedulerUseCase.isEmpty()) {
startButton.setDisable(true);
startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
stopButton.setDisable(true);
intervalField.setEditable(false);
intervalField.setDisable(true);
} else {
intervalField.setText(String.valueOf(schedulerUseCase.get().getIntervalSeconds()));
}
}
// -------------------------------------------------------------------------
// updateStatus-Hilfsmethoden
// -------------------------------------------------------------------------
private void updateStatusLabel(SchedulerStatus status) {
switch (status.state()) {
case STOPPED -> {
statusLabel.setText("○ Gestoppt");
statusLabel.setStyle(HEADER_LABEL_STYLE);
}
case STARTING -> {
statusLabel.setText("⟳ Wird gestartet…");
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #e67e22;");
}
case RUNNING_IDLE -> {
statusLabel.setText("● Aktiv");
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
}
case RUNNING_BATCH_ACTIVE -> {
statusLabel.setText("● Aktiv Lauf aktiv");
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
}
case STOPPING_BATCH_ACTIVE -> {
statusLabel.setText("○ Gestoppt aktueller Lauf läuft noch");
statusLabel.setStyle(HEADER_LABEL_STYLE);
}
}
}
private void updateButtons(SchedulerStatus status) {
boolean noUseCase = schedulerUseCase.isEmpty();
boolean configDirty = Boolean.TRUE.equals(isConfigDirty.get());
switch (status.state()) {
case STOPPED -> {
stopButton.setDisable(true);
if (noUseCase) {
startButton.setDisable(true);
startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
} else if (configDirty) {
startButton.setDisable(true);
startButton.setTooltip(new Tooltip("Bitte Konfiguration speichern"));
} else {
startButton.setDisable(false);
startButton.setTooltip(null);
}
}
case STARTING -> {
startButton.setDisable(true);
stopButton.setDisable(true);
}
case RUNNING_IDLE, RUNNING_BATCH_ACTIVE -> {
startButton.setDisable(true);
startButton.setTooltip(null);
stopButton.setDisable(false);
}
case STOPPING_BATCH_ACTIVE -> {
startButton.setDisable(true);
stopButton.setDisable(true);
}
}
}
private void updateNextTickLabel(SchedulerStatus status) {
if (status.state() == SchedulerState.RUNNING_IDLE && status.nextTickAt().isPresent()) {
long remaining = ChronoUnit.SECONDS.between(Instant.now(), status.nextTickAt().get());
if (remaining > 0) {
long minutes = remaining / 60;
long seconds = remaining % 60;
nextTickLabel.setText(String.format("Nächster Lauf in: %02d:%02d", minutes, seconds));
} else {
nextTickLabel.setText("Lauf steht bevor…");
}
nextTickLabel.setVisible(true);
nextTickLabel.setManaged(true);
} else {
nextTickLabel.setVisible(false);
nextTickLabel.setManaged(false);
}
}
private void updateLastRunLabel(SchedulerStatus status) {
if (status.lastRunEndedAt().isPresent() && status.lastRunSummary().isPresent()) {
Instant endedAt = status.lastRunEndedAt().get();
RunSummary summary = status.lastRunSummary().get();
String timeStr = TIME_FORMATTER.format(endedAt);
boolean noDocuments = summary.successCount() == 0
&& summary.failedCount() == 0;
if (noDocuments) {
lastRunLabel.setText("Letzter Lauf: " + timeStr + " keine neuen Dokumente");
} else {
lastRunLabel.setText("Letzter Lauf: " + timeStr + " "
+ summary.successCount() + " verarbeitet, "
+ summary.failedCount() + " Fehler");
}
} else {
lastRunLabel.setText("Noch kein Lauf in dieser Sitzung.");
}
}
private void updateSessionTotalsLabel(SchedulerStatus status) {
Optional<SchedulerSessionTotals> totals = status.sessionTotals();
if (totals.isPresent()) {
SchedulerSessionTotals t = totals.get();
sessionTotalsLabel.setText("Seit Scheduler-Start: "
+ t.successCount() + " verarbeitet, "
+ t.failedCount() + " Fehler");
sessionTotalsLabel.setVisible(true);
sessionTotalsLabel.setManaged(true);
} else {
sessionTotalsLabel.setVisible(false);
sessionTotalsLabel.setManaged(false);
}
}
private void updateLastErrorLabel(SchedulerStatus status) {
Optional<String> lastError = status.lastError();
if (lastError.isPresent() && !lastError.get().isBlank()) {
lastErrorLabel.setText("Fehler: " + lastError.get());
lastErrorLabel.setVisible(true);
lastErrorLabel.setManaged(true);
} else {
lastErrorLabel.setVisible(false);
lastErrorLabel.setManaged(false);
}
}
private void updateIntervalFieldEditability(SchedulerStatus status) {
boolean editable = status.state() == SchedulerState.STOPPED
&& schedulerUseCase.isPresent()
&& !Boolean.TRUE.equals(isConfigDirty.get());
intervalField.setEditable(editable);
intervalField.setDisable(!editable);
}
// -------------------------------------------------------------------------
// Aktions-Handler
// -------------------------------------------------------------------------
private void executeStart() {
LOG.info("GUI: Scheduler-Start angefordert.");
startButton.setDisable(true);
stopButton.setDisable(true);
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
try {
uc.start();
LOG.info("GUI: Scheduler erfolgreich gestartet.");
} catch (SchedulerStartException e) {
LOG.warn("GUI: Scheduler-Start fehlgeschlagen: {}", e.getMessage());
Platform.runLater(() -> showStartErrorAlert(e.getMessage()));
} catch (RuntimeException e) {
LOG.error("GUI: Unerwarteter Fehler beim Starten des Schedulers.", e);
Platform.runLater(() -> showStartErrorAlert("Unerwarteter Fehler: " + e.getMessage()));
}
}));
}
private void executeStop() {
LOG.info("GUI: Scheduler-Stopp angefordert.");
startButton.setDisable(true);
stopButton.setDisable(true);
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
try {
uc.stop();
LOG.info("GUI: Scheduler gestoppt.");
} catch (RuntimeException e) {
LOG.error("GUI: Unerwarteter Fehler beim Stoppen des Schedulers.", e);
}
}));
}
private void validateAndSaveInterval() {
String text = intervalField.getText() == null ? "" : intervalField.getText().trim();
try {
int value = Integer.parseInt(text);
if (value < MIN_INTERVAL_SECONDS) {
showIntervalValidationError(
"Mindestintervall ist " + MIN_INTERVAL_SECONDS + " Sekunden.");
} else {
hideIntervalValidationError();
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
try {
uc.saveIntervalSeconds(value);
} catch (RuntimeException e) {
LOG.warn("GUI: Fehler beim Speichern des Scheduler-Intervalls: {}", e.getMessage());
Platform.runLater(() -> showIntervalValidationError(
"Speichern fehlgeschlagen: " + e.getMessage()));
}
}));
}
} catch (NumberFormatException e) {
showIntervalValidationError("Bitte eine ganze Zahl eingeben.");
}
}
private void showIntervalValidationError(String message) {
intervalValidationLabel.setText(message);
intervalValidationLabel.setVisible(true);
intervalValidationLabel.setManaged(true);
}
private void hideIntervalValidationError() {
intervalValidationLabel.setVisible(false);
intervalValidationLabel.setManaged(false);
}
private static void showStartErrorAlert(String message) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Scheduler-Start fehlgeschlagen");
alert.setHeaderText("Der Scheduler konnte nicht gestartet werden.");
alert.setContentText(message != null ? message : "Unbekannter Fehler.");
alert.showAndWait();
}
}
@@ -2,9 +2,25 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalDocumentContextPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
@@ -13,6 +29,7 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheck
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Immutable startup data for the GUI adapter.
@@ -24,9 +41,39 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.Technical
* API key provenance from environment variables, the {@link ProviderTechnicalTestService}
* used to execute provider-specific technical checks, the {@link PathCheckPort}
* used to verify filesystem path accessibility for configuration values, the
* {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, and the
* {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, the
* {@link CorrectionExecutionService} used to execute corrective actions after a
* technical test run has been confirmed by the user.
* technical test run has been confirmed by the user, the {@link GuiBatchRunLauncher} used
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
* reset the persistence status of selected documents, and the
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI,
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
* folder for documents that have not yet been successfully processed, and
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
* context for documents that were skipped in the current run, the resolved application
* version string that the status bar displays at the bottom of the main window, and the
* optional {@link SchedulerControlUseCase} for controlling the automatic scheduler.
* <p>
* The optional {@code applicationContextError} carries a human-readable German error
* message when the bootstrap-side application run context could not be initialised at
* startup (e.g., invalid or incomplete configuration). An empty value signals that the
* run context was built successfully and batch runs can be launched immediately.
* <p>
* The optional {@code schedulerControlUseCase} is present when the automatic scheduler
* was successfully wired at startup. An empty value means scheduler control is not
* available in this startup context (e.g., no valid configuration was loaded at startup).
* <p>
* The optional {@code configurationFileLockPort} is present when the GUI can acquire an
* OS-level exclusive lock on the configuration file before a manual batch run. When present,
* it is acquired by the {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}
* on the worker thread before each run and released in a finally block. An empty value means
* no locking is performed (e.g., no valid configuration was loaded at startup, or locking is
* not required in this context).
* <p>
* The {@code applicationContextInitializer} is invoked on a background thread each time the
* workspace loads a configuration file (auto-load at startup and manual open). Bootstrap
* provides an implementation that builds the application run context and wires the scheduler.
* <p>
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
* know about provider-specific HTTP details or adapter wiring.
@@ -41,10 +88,32 @@ public record GuiStartupContext(
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService) {
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort,
Optional<String> applicationContextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase,
Optional<ConfigurationFileLockPort> configurationFileLockPort,
GuiApplicationContextInitializer applicationContextInitializer,
Optional<GuiModelPriceManagementPort> modelPriceManagementPort) {
private static final String NO_PROMPT_PORT_MSG = "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.";
private static final String NO_PORT_MSG = "Kein Port in diesem Startkontext.";
/**
* Creates a startup context.
* Creates a fully wired startup context.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
@@ -56,10 +125,31 @@ public record GuiStartupContext(
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run for selected
* documents; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
* documents; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
* must not be {@code null}
* @param manualFileCopyPort bridge that copies a source file to the target folder for
* documents that have not yet been successfully processed;
* must not be {@code null}
* @param historicalDocumentContextPort bridge that resolves the historical processing context
* for skipped documents; must not be {@code null}
* @param applicationVersion resolved application version string shown in the status
* bar; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
* {@code null} sein
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
* darf nicht {@code null} sein
* @param applicationContextError optional error message when the application run context
* could not be initialised at startup; {@code null} becomes empty
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
startupNotice = startupNotice == null ? Optional.empty() : startupNotice;
startupNotice = Objects.requireNonNullElse(startupNotice, Optional.empty());
applicationContextError = Objects.requireNonNullElse(applicationContextError, Optional.empty());
configurationFileLoader = Objects.requireNonNull(configurationFileLoader,
"configurationFileLoader must not be null");
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
@@ -76,22 +166,343 @@ public record GuiStartupContext(
"technicalTestOrchestrator must not be null");
correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
"correctionExecutionService must not be null");
batchRunLauncher = Objects.requireNonNull(batchRunLauncher,
"batchRunLauncher must not be null");
miniRunLauncher = Objects.requireNonNull(miniRunLauncher,
"miniRunLauncher must not be null");
resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
"resetDocumentStatusPort must not be null");
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
"manualFileRenamePort must not be null");
manualFileCopyPort = Objects.requireNonNull(manualFileCopyPort,
"manualFileCopyPort must not be null");
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
"historicalDocumentContextPort must not be null");
// Null-Fallback für Testumgebungen ohne gepacktes JAR
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
"historyOverviewPort must not be null");
historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
"historyDetailsPort must not be null");
historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
"historyResetDocumentStatusPort must not be null");
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
"deleteDocumentHistoryPort must not be null");
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
"promptEditorPortFactory must not be null");
createNewDatabasePort = Objects.requireNonNull(createNewDatabasePort,
"createNewDatabasePort must not be null");
schedulerControlUseCase = Objects.requireNonNullElse(schedulerControlUseCase, Optional.empty());
configurationFileLockPort = Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
applicationContextInitializer = applicationContextInitializer == null
? GuiApplicationContextInitializer.noOp() : applicationContextInitializer;
modelPriceManagementPort = Objects.requireNonNullElse(modelPriceManagementPort, Optional.empty());
}
/**
* Creates a blank startup context with no loader or writer side effects, a no-op model
* catalogue port, a no-op API key resolution port, a no-op provider technical test service,
* a no-op path check port, a no-op technical test orchestrator, and a no-op
* correction execution service.
* Backward-compatible constructor that fills {@code schedulerControlUseCase} with
* {@link Optional#empty()}.
* <p>
* Preserves existing callers that were written before the scheduler was added.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets document status; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file; must not be {@code null}
* @param manualFileCopyPort bridge that copies a source file; must not be {@code null}
* @param historicalDocumentContextPort bridge for historical processing context; must not be {@code null}
* @param applicationVersion resolved application version string; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; must not be {@code null}
* @param historyOverviewPort bridge for history overview; must not be {@code null}
* @param historyDetailsPort bridge for history details; must not be {@code null}
* @param historyResetDocumentStatusPort bridge for history reset; must not be {@code null}
* @param deleteDocumentHistoryPort bridge for history deletion; must not be {@code null}
* @param promptEditorPortFactory factory for prompt editor ports; must not be {@code null}
* @param createNewDatabasePort bridge for new database creation; must not be {@code null}
* @param applicationContextError optional error from context init; {@code null} becomes empty
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort,
Optional<String> applicationContextError) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, manualFileRenamePort, manualFileCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, Optional.empty(), Optional.empty(),
GuiApplicationContextInitializer.noOp(), Optional.empty());
}
/**
* Backward-compatible constructor that fills {@code configurationFileLockPort} with
* {@link Optional#empty()}.
* <p>
* Preserves existing callers that were written before the configuration file lock port
* was added.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets document status; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file; must not be {@code null}
* @param manualFileCopyPort bridge that copies a source file; must not be {@code null}
* @param historicalDocumentContextPort bridge for historical processing context; must not be {@code null}
* @param applicationVersion resolved application version string; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; must not be {@code null}
* @param historyOverviewPort bridge for history overview; must not be {@code null}
* @param historyDetailsPort bridge for history details; must not be {@code null}
* @param historyResetDocumentStatusPort bridge for history reset; must not be {@code null}
* @param deleteDocumentHistoryPort bridge for history deletion; must not be {@code null}
* @param promptEditorPortFactory factory for prompt editor ports; must not be {@code null}
* @param createNewDatabasePort bridge for new database creation; must not be {@code null}
* @param applicationContextError optional error from context init; {@code null} becomes empty
* @param schedulerControlUseCase optional scheduler control use case; {@code null} becomes empty
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort,
Optional<String> applicationContextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, manualFileRenamePort, manualFileCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, schedulerControlUseCase, Optional.empty(),
GuiApplicationContextInitializer.noOp(), Optional.empty());
}
/**
* Backward-compatible constructor that fills the manual-rename port with a no-op
* implementation.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run for selected
* documents; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
* documents; must not be {@code null}
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(), Optional.empty());
}
/**
* Backward-compatible constructor that fills the mini-run launcher, reset port and
* manual-rename port with no-op implementations.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(), Optional.empty());
}
/**
* Backward-compatible constructor that fills the processing-run launcher, mini-run
* launcher, reset port and manual-rename port with no-op implementations.
* <p>
* Preserves existing callers that were written before the processing-run tab was added.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(), Optional.empty());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
return (configPath, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
}
private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
return (configPath, filter, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
"Kein Mini-Run-Launcher in diesem Startkontext verfügbar.");
}
private static GuiResetDocumentStatusPort rejectingResetPort() {
return (configPath, fingerprints) -> {
java.util.Map<DocumentFingerprint, String> failures = new java.util.HashMap<>();
for (DocumentFingerprint fp : fingerprints) {
failures.put(fp, "Kein Reset-Port in diesem Startkontext verfügbar.");
}
return new ResetDocumentStatusResult(fingerprints.size(), Set.of(), failures);
};
}
private static GuiManualFileRenamePort rejectingManualFileRenamePort() {
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileRenameFileSystemFailure(
"Kein Umbenennungs-Port in diesem Startkontext verfügbar.");
}
private static GuiManualFileCopyPort rejectingManualFileCopyPort() {
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileCopyFileSystemFailure(
"Kein Kopier-Port in diesem Startkontext verfügbar.");
}
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
return (configPath, fingerprint) -> java.util.Optional.empty();
}
/**
* Creates a blank startup context with no-op implementations for all ports and services.
* <p>
* The no-op model catalogue port always returns {@code IncompleteConfiguration}.
* The no-op API key resolution port always returns {@code ABSENT}.
* The no-op provider technical test service uses the no-op ports above.
* The no-op path check port always returns {@code false} for all checks.
* The no-op technical test orchestrator returns a report where all checkpoints are
* {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult.NotApplicable}.
* The no-op correction execution service uses a no-op {@link ResourceCreationPort} that always
* returns {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted}.
* This is safe for environments where no Bootstrap wiring is present, such as isolated
* GUI tests.
*
@@ -120,31 +531,35 @@ public record GuiStartupContext(
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
noOpPathCheckPort,
noOpTestService);
noOpTestService,
() -> java.util.Optional.empty());
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreateDirectory suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.PrepareSqlitePath suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
}
};
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
GuiBatchRunLauncher noOpBatchRunLauncher = (configPath, observer, token) ->
GuiBatchRunLaunchOutcome.rejected(
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
startupNotice,
@@ -155,6 +570,89 @@ public record GuiStartupContext(
noOpTestService,
noOpPathCheckPort,
noOpOrchestrator,
noOpCorrectionService);
noOpCorrectionService,
noOpBatchRunLauncher,
rejectingMiniRunLauncher(),
rejectingResetPort(),
rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(),
"dev",
noOpPromptEditorPort(),
noOpHistoryOverviewPort(),
noOpHistoryDetailsPort(),
noOpHistoryResetPort(),
noOpDeleteHistoryPort(),
noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort(),
Optional.empty());
}
/**
* Liefert einen ablehnenden {@link GuiCreateNewDatabasePort}, der jede Anlage
* sofort als Fehler zurückgibt. Wird verwendet, wenn kein Bootstrap-seitig
* verdrahteter Port vorliegt (z. B. in Tests oder vor dem Laden einer
* Konfiguration).
*
* @return ein ablehnender Port; nie {@code null}
*/
private static GuiCreateNewDatabasePort rejectingCreateNewDatabasePort() {
return (configFilePath, targetFile) -> new de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed(
de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
"Kein DB-Anlage-Port in diesem Startkontext verfügbar.",
null);
}
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
return path -> noOpPromptEditorPort();
}
private static GuiPromptEditorPort noOpPromptEditorPort() {
return new GuiPromptEditorPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
"NO_OP", NO_PROMPT_PORT_MSG);
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
NO_PROMPT_PORT_MSG, null);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDefaultPromptIfMissing(
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(
suggestion, NO_PROMPT_PORT_MSG);
}
};
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort
noOpHistoryOverviewPort() {
return (configFilePath, query) -> new de.gecheckt.pdf.umbenenner.application.usecase
.DefaultHistoryOverviewUseCase.HistoryOverviewResult(java.util.List.of(), false);
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort
noOpHistoryDetailsPort() {
return (configFilePath, fingerprint) -> java.util.Optional.empty();
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort
noOpHistoryResetPort() {
return (configFilePath, fingerprint) -> { /* kein Reset in diesem Startkontext */ };
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort
noOpDeleteHistoryPort() {
return (configFilePath, fingerprint) -> { /* kein Löschen in diesem Startkontext */ };
}
}
@@ -0,0 +1,199 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.Separator;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
/**
* Permanente Statuszeile am unteren Rand des Hauptfensters.
* <p>
* Die Statuszeile zeigt immer drei Segmente:
* <ul>
* <li><b>Links:</b> Anwendungsversion im Format {@code V<version>}, z.&nbsp;B. {@code Vdev}.</li>
* <li><b>Mitte:</b> Aktiver Provider und Modellname aus der geladenen Konfiguration.</li>
* <li><b>Rechts:</b> Pfad der geladenen Konfigurationsdatei.</li>
* </ul>
* Wenn keine Konfiguration geladen ist, zeigen Mitte und Rechts den Text
* {@value #KEIN_PROFIL_TEXT}. Die Versionsanzeige ist stets sichtbar.
* <p>
* Alle Aktualisierungen dieser Komponente müssen auf dem JavaFX Application Thread erfolgen.
* Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung.
*/
public final class GuiStatusBar {
private static final String LABEL_STYLE = "-fx-font-size: 11px; -fx-text-fill: #555555;";
/** Anzeigetext wenn keine Konfiguration geladen ist. */
static final String KEIN_PROFIL_TEXT = "Kein Profil geladen";
/** Präfix vor der Versionsnummer in der linken Statuszeilen-Zelle. */
private static final String VERSION_PREFIX = "V";
private static final AiProviderFamilyStringConverter PROVIDER_CONVERTER =
new AiProviderFamilyStringConverter();
private final String applicationVersion;
private final BorderPane root;
private final Label versionLabel;
private final Label providerLabel;
private final Label configPathLabel;
/**
* Erstellt eine neue Statuszeile mit der angegebenen Anwendungsversion.
*
* @param applicationVersion die aufgelöste Versionsnummer; {@code null} oder leer führt zum
* Fallback {@code "dev"}
*/
public GuiStatusBar(String applicationVersion) {
this.applicationVersion = (applicationVersion == null || applicationVersion.isBlank())
? "dev"
: applicationVersion;
// Linkes Segment: Versionsanzeige
this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion);
this.versionLabel.setStyle(LABEL_STYLE);
// Mittleres Segment: Provider und Modell
this.providerLabel = new Label(KEIN_PROFIL_TEXT);
this.providerLabel.setStyle(LABEL_STYLE);
this.providerLabel.setAlignment(Pos.CENTER);
// Rechtes Segment: Konfigurationspfad
this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
this.configPathLabel.setStyle(LABEL_STYLE);
this.configPathLabel.setAlignment(Pos.CENTER_RIGHT);
// Abstandhalter zwischen den Segmenten
Region leftSpacer = new Region();
Region rightSpacer = new Region();
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
HBox content = new HBox(16,
versionLabel, leftSpacer,
providerLabel, rightSpacer,
configPathLabel);
content.setAlignment(Pos.CENTER_LEFT);
content.setPadding(new Insets(4, 12, 4, 12));
content.setStyle("-fx-background-color: #f5f5f5;");
Separator topSeparator = new Separator();
this.root = new BorderPane();
this.root.setTop(topSeparator);
this.root.setCenter(content);
}
/**
* Gibt den Wurzelknoten der Statuszeile zurück, der in das Hauptfenster eingebettet wird.
*
* @return der Wurzelknoten; nie {@code null}
*/
public BorderPane root() {
return root;
}
/**
* Aktualisiert die Statuszeile anhand des aktuellen Editor-Zustands.
* <p>
* Ist kein Dateisnapshot vorhanden, wird {@link #clearConfiguration()} ausgeführt.
* Andernfalls werden Provider, Modell und Konfigurationspfad aus dem Zustand ermittelt
* und angezeigt.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param state der aktuelle Editor-Zustand; darf nicht {@code null} sein
*/
public void applyEditorState(GuiConfigurationEditorState state) {
if (state == null || !state.hasLoadedFileSnapshot()) {
clearConfiguration();
return;
}
String configPath = state.configurationPathText();
String providerText = resolveProviderText(state);
providerLabel.setText(providerText);
configPathLabel.setText(configPath.isBlank() ? KEIN_PROFIL_TEXT : configPath);
}
/**
* Setzt Mitte und Rechts der Statuszeile auf den Text {@link #KEIN_PROFIL_TEXT} zurück.
* <p>
* Die Versionsanzeige bleibt unverändert.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clearConfiguration() {
providerLabel.setText(KEIN_PROFIL_TEXT);
configPathLabel.setText(KEIN_PROFIL_TEXT);
}
/**
* Gibt den aktuell angezeigten Versionstext zurück (inkl. Präfix {@code V}).
* <p>
* Für Tests zugänglich.
*
* @return der angezeigte Versionstext; nie {@code null}
*/
String versionText() {
return versionLabel.getText();
}
/**
* Gibt den aktuell angezeigten Provider-Text zurück.
* <p>
* Für Tests zugänglich.
*
* @return der angezeigte Provider-Text; nie {@code null}
*/
String providerText() {
return providerLabel.getText();
}
/**
* Gibt den aktuell angezeigten Konfigurationspfad-Text zurück.
* <p>
* Für Tests zugänglich.
*
* @return der angezeigte Konfigurationspfad-Text; nie {@code null}
*/
String configPathText() {
return configPathLabel.getText();
}
/**
* Ermittelt den anzuzeigenden Provider-Text aus dem Editor-Zustand.
* <p>
* Das Format ist: {@code Provider: <AnzeigeName> · <Modellname>}, wobei der Modellname
* weggelassen wird, wenn er leer ist.
*
* @param state der Editor-Zustand; darf nicht {@code null} sein
* @return der formatierte Provider-Text; nie {@code null}
*/
private static String resolveProviderText(GuiConfigurationEditorState state) {
String activeIdentifier = state.values().activeProviderFamily();
if (activeIdentifier == null || activeIdentifier.isBlank()) {
return KEIN_PROFIL_TEXT;
}
AiProviderFamily family = AiProviderFamily.fromIdentifier(activeIdentifier).orElse(null);
if (family == null) {
return KEIN_PROFIL_TEXT;
}
String displayName = PROVIDER_CONVERTER.toString(family);
GuiProviderConfigurationState providerState = state.values().providerConfiguration(family);
String model = providerState != null ? providerState.model() : "";
if (model == null || model.isBlank()) {
return "Provider: " + displayName;
}
return "Provider: " + displayName + " · " + model;
}
}
@@ -0,0 +1,68 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.util.Duration;
/**
* Zentrale Status-Refresh-Timeline für die GUI.
* <p>
* Startet eine JavaFX-{@link Timeline}, die im Sekundentakt einen Callback aufruft.
* Der Callback liest den aktuellen Scheduler-Status und aktualisiert alle betroffenen
* Tabs (Batch-Tab, Konfig-Tab, Scheduler-Tab) auf dem JavaFX Application Thread.
* <p>
* Die Timeline wird beim Aufbau der Haupt-GUI gestartet und beim Beenden der
* Anwendung gestoppt. Sie läuft unabhängig davon, welcher Tab gerade sichtbar ist.
* <p>
* Wenn kein {@link SchedulerControlUseCase} vorhanden ist, wird der Callback trotzdem
* aufgerufen der Aufrufer entscheidet, wie er das leere Optional behandelt.
*/
public final class GuiStatusRefreshTimeline {
private final Timeline timeline;
/**
* Erzeugt eine neue Status-Refresh-Timeline.
* <p>
* Die Timeline ist nach der Konstruktion noch nicht aktiv; {@link #start()} muss
* explizit aufgerufen werden.
*
* @param schedulerControlUseCase optionaler Scheduler-Control-Use-Case;
* {@code null} wird als leer behandelt
* @param onRefresh Callback der bei jedem Tick auf dem JavaFX Application
* Thread aufgerufen wird; darf nicht {@code null} sein
*/
public GuiStatusRefreshTimeline(
Optional<SchedulerControlUseCase> schedulerControlUseCase,
Runnable onRefresh) {
Objects.requireNonNull(onRefresh, "onRefresh must not be null");
this.timeline = new Timeline(
new KeyFrame(Duration.seconds(1), e -> onRefresh.run()));
this.timeline.setCycleCount(Animation.INDEFINITE);
}
/**
* Startet die Status-Refresh-Timeline.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
* Mehrfache Aufrufe sind unschädlich.
*/
public void start() {
timeline.play();
}
/**
* Stoppt die Status-Refresh-Timeline.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
* Mehrfache Aufrufe sind unschädlich.
*/
public void stop() {
timeline.stop();
}
}
@@ -64,6 +64,7 @@ public final class GuiTechnicalTestCoordinator {
private final TechnicalTestOrchestrator orchestrator;
private final Supplier<EditorValidationInput> inputProvider;
private final Supplier<String> configFilePathProvider;
private final Supplier<String> logDirectoryProvider;
private final List<GuiMessageEntry> pendingMessages;
private final Consumer<TechnicalTestReport> postResultCallback;
@@ -89,6 +90,9 @@ public final class GuiTechnicalTestCoordinator {
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
* darf nicht {@code null} sein
* @param logDirectoryProvider Lieferant des konfigurierten {@code log.directory}-Rohwerts;
* gibt eine leere Zeichenkette zurück wenn kein Wert konfiguriert ist;
* darf nicht {@code null} sein
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
* @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; darf nicht {@code null} sein
* @throws NullPointerException wenn einer der Parameter {@code null} ist
@@ -96,11 +100,13 @@ public final class GuiTechnicalTestCoordinator {
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
Supplier<EditorValidationInput> inputProvider,
Supplier<String> configFilePathProvider,
Supplier<String> logDirectoryProvider,
List<GuiMessageEntry> pendingMessages,
Consumer<TechnicalTestReport> postResultCallback) {
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider must not be null");
this.configFilePathProvider = Objects.requireNonNull(configFilePathProvider, "configFilePathProvider must not be null");
this.logDirectoryProvider = Objects.requireNonNull(logDirectoryProvider, "logDirectoryProvider must not be null");
this.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages must not be null");
this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
this.testThreadFactory = task -> {
@@ -113,6 +119,9 @@ public final class GuiTechnicalTestCoordinator {
/**
* Löst die asynchrone Ausführung des vollständigen technischen Gesamttests aus.
* <p>
* Vor dem Worker-Start wird die geteilte Nachrichtenliste auf dem FX-Thread geleert;
* jeder Aufruf ersetzt die zuvor angefügten Einträge (Replace-Semantik).
* <p>
* Liest den aktuellen Editorzustand und den Konfigurationsdateipfad, baut einen
* {@link TechnicalTestRequest} und startet den {@link TechnicalTestOrchestrator} auf
* einem Hintergrund-Worker-Thread. Das Ergebnis wird via {@code resultDelivery} an den
@@ -124,9 +133,15 @@ public final class GuiTechnicalTestCoordinator {
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void triggerTechnicalTests() {
// Bestehende Nachrichtenliste auf dem FX-Thread leeren, bevor der Worker-Thread
// startet. Dadurch laufen clear() und nachfolgende add()-Aufrufe (die per
// Platform.runLater wieder auf dem FX-Thread landen) auf demselben Thread und
// es entsteht kein Race-Fenster mit der UI.
pendingMessages.clear();
EditorValidationInput input = inputProvider.get();
String configFilePath = configFilePathProvider.get();
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath);
String logDirectory = logDirectoryProvider.get();
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath, logDirectory);
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
@@ -146,15 +161,14 @@ public final class GuiTechnicalTestCoordinator {
* Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
* <p>
* Fügt für jedes Checkpoint-Ergebnis einen neuen Eintrag zur geteilten Nachrichtenliste
* hinzu; vorhandene Einträge bleiben erhalten, sodass die Meldungen über mehrere
* Testläufe hinweg akkumulieren. Zusätzlich wird eine Zusammenfassung angehängt.
* hinzu. Die Liste wurde zuvor in {@link #triggerTechnicalTests()} geleert, sodass jeder
* Aufruf einen frischen Stand erzeugt. Zusätzlich wird eine Zusammenfassung angehängt.
* <p>
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
*
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
*/
private void applyResult(TechnicalTestReport report) {
// Akkumulieren: Vorherige Einträge anderer Läufe bleiben erhalten.
long successCount = 0;
long failureErrorCount = 0;
@@ -227,6 +241,7 @@ public final class GuiTechnicalTestCoordinator {
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
case LOG_DIRECTORY_USABLE -> "Log-Verzeichnis beschreibbar";
};
}
@@ -0,0 +1,296 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
/**
* Zentrale Konstantenklasse für alle Tooltip-Texte der GUI.
* <p>
* Diese Klasse ist die einzige autoritative Quelle für Tooltip-Beschriftungen aller
* interaktiven Elemente in der Desktop-Oberfläche. Alle Tooltip-Strings werden hier
* definiert und von den jeweiligen UI-Klassen referenziert. Streustrings im
* UI-Code sind unzulässig.
* <p>
* Tooltip-Texte für Status-Icons werden <em>nicht</em> hier gepflegt sie stammen
* ausschließlich aus {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation},
* die die autoritative Quelle für alle statusbezogenen Darstellungsinformationen ist.
* <p>
* Alle Texte sind deutschsprachig gemäß Spezifikation.
* Diese Klasse enthält keine JavaFX-Typen und ist nicht instanziierbar.
*/
public final class GuiTooltipTexts {
// -------------------------------------------------------------------------
// Toolbar-Buttons
// -------------------------------------------------------------------------
/** Tooltip für den Button „Neu". */
public static final String TOOLBAR_NEU =
"Neue Konfiguration erstellen.";
/** Tooltip für den Button „Öffnen". */
public static final String TOOLBAR_OEFFNEN =
"Bestehende Konfigurationsdatei (.properties) öffnen.";
/** Tooltip für den Button „Speichern". */
public static final String TOOLBAR_SPEICHERN =
"Aktuelle Konfiguration speichern.";
/** Tooltip für den Button „Speichern unter". */
public static final String TOOLBAR_SPEICHERN_UNTER =
"Konfiguration unter neuem Dateipfad speichern.";
/** Tooltip für den Button „Validieren". */
public static final String TOOLBAR_VALIDIEREN =
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.";
/** Tooltip für den Button „Technische Tests ausführen". */
public static final String TOOLBAR_TECHNISCHE_TESTS =
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.";
// -------------------------------------------------------------------------
// Konfigurationstab Pfade
// -------------------------------------------------------------------------
/** Tooltip für das Eingabefeld „Quellordner". */
public static final String PFADE_QUELLORDNER =
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.";
/** Tooltip für das Eingabefeld „Zielordner". */
public static final String PFADE_ZIELORDNER =
"Ordner für die umbenannten Kopien.";
/** Tooltip für das Eingabefeld „SQLite-Datei". */
public static final String PFADE_SQLITE =
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.";
/** Tooltip für das Eingabefeld „Prompt-Datei". */
public static final String PFADE_PROMPT =
"Externe Textdatei mit den KI-Anweisungen.";
/** Tooltip für das Eingabefeld „Lock-Datei". */
public static final String PFADE_LOCK_DATEI =
"Pfad zur Lock-Datei, die parallele Instanzen verhindert (optional).";
/** Tooltip für das Eingabefeld „Log-Verzeichnis". */
public static final String PFADE_LOG_VERZEICHNIS =
"Verzeichnis für Log-Dateien. Leer = Standardverzeichnis logs/ im Programmverzeichnis.";
// -------------------------------------------------------------------------
// Konfigurationstab Provider
// -------------------------------------------------------------------------
/** Tooltip für die Provider-ComboBox. */
public static final String PROVIDER_COMBOBOX =
"Der KI-Dienst, der die Dateinamen generiert.";
/** Tooltip für das Modell-Eingabefeld (ComboBox oder manuelles TextField). */
public static final String PROVIDER_MODELL =
"Das konkrete Sprachmodell des gewählten Providers.";
/** Tooltip für das Eingabefeld „Basis-URL". */
public static final String PROVIDER_BASIS_URL =
"Basis-URL des KI-Dienstes (z. B. https://api.openai.com/v1).";
/** Tooltip für das Eingabefeld „Timeout". */
public static final String PROVIDER_TIMEOUT =
"Zeitlimit für KI-Anfragen in Sekunden.";
/** Tooltip für das Eingabefeld „API-Key". */
public static final String PROVIDER_API_KEY =
"API-Schlüssel für den konfigurierten KI-Dienst. Umgebungsvariable hat Vorrang.";
// -------------------------------------------------------------------------
// Konfigurationstab Verarbeitungslimits
// -------------------------------------------------------------------------
/** Tooltip für das Eingabefeld „max.text.characters". */
public static final String LIMITS_MAX_TEXT_CHARACTERS =
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.";
/** Tooltip für das Eingabefeld „max.pages". */
public static final String LIMITS_MAX_PAGES =
"Maximale Seitenzahl, die aus einem PDF gelesen wird.";
/** Tooltip für das Eingabefeld „max.title.length". */
public static final String LIMITS_MAX_TITLE_LENGTH =
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10120.";
/** Tooltip für das Eingabefeld „max.retries.transient". */
public static final String LIMITS_MAX_RETRIES =
"Maximale Anzahl transienter Wiederholversuche je Dokument (Ganzzahl ≥ 1).";
/** Tooltip für das Eingabefeld „Log-Level". */
public static final String LIMITS_LOG_LEVEL =
"Log-Detailstufe (z. B. INFO, DEBUG, WARN). Leer = Standardwert INFO.";
/** Tooltip für die Checkbox „Sensible KI-Ausgabe". */
public static final String LIMITS_SENSIBLE_KI_AUSGABE =
"Vollständige KI-Antworten in die Log-Datei schreiben (nur für Diagnosezwecke empfohlen).";
// -------------------------------------------------------------------------
// Verarbeitungslauf-Tab Dateiname-Editor
// -------------------------------------------------------------------------
/** Tooltip für das Dateiname-Textfeld im Dateiname-Editor. */
public static final String DATEINAME_TEXTFELD =
"Dateiname bearbeiten. Format: JJJJ-MM-TT - Titel.pdf";
/** Tooltip für den Button „Dateiname übernehmen". */
public static final String DATEINAME_UEBERNEHMEN =
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.";
/** Tooltip für den Button „Zurücksetzen auf KI-Vorschlag". */
public static final String DATEINAME_ZURUECKSETZEN =
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.";
// -------------------------------------------------------------------------
// Verarbeitungslauf-Tab Laufsteuerung und Tabelle
// -------------------------------------------------------------------------
/** Tooltip für den Button „Starten". */
public static final String BATCHRUN_STARTEN =
"Verarbeitungslauf starten: alle ausstehenden PDF-Dateien aus dem Quellordner verarbeiten.";
/** Tooltip für den Button „Abbrechen". */
public static final String BATCHRUN_ABBRECHEN =
"Laufenden Verarbeitungslauf abbrechen. Bereits abgeschlossene Dateien bleiben gespeichert.";
/** Tooltip für den Button „Erneut verarbeiten". */
public static final String BATCHRUN_ERNEUT_VERARBEITEN =
"Markierte Einträge erneut zur Verarbeitung freigeben (setzt Status auf READY_FOR_AI).";
/** Tooltip für den Button „Status zurücksetzen" im Verarbeitungslauf-Tab. */
public static final String BATCHRUN_STATUS_ZURUECKSETZEN =
"Status der markierten Einträge zurücksetzen, damit sie beim nächsten Lauf verarbeitet werden.";
/** Tooltip für die Master-Checkbox im Tabellenkopf des Verarbeitungslauf-Tabs. */
public static final String BATCHRUN_MASTER_CHECKBOX =
"Alle sichtbaren Einträge markieren oder Markierung aufheben.";
/** Tooltip für den Meldungsbereich im Verarbeitungslauf-Tab. */
public static final String BATCHRUN_MESSAGE_AREA =
"Statusmeldungen und Fortschrittsinformationen des aktuellen Verarbeitungslaufs.";
/** Tooltip für den Navigations-Button „Vorherige Seite" in der PDF-Vorschau. */
public static final String PREVIEW_VORHERIGE_SEITE =
"Vorherige Seite der Vorschau anzeigen.";
/** Tooltip für den Navigations-Button „Nächste Seite" in der PDF-Vorschau. */
public static final String PREVIEW_NAECHSTE_SEITE =
"Nächste Seite der Vorschau anzeigen.";
/** Tooltip für Spalte „Status" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_STATUS =
"Verarbeitungsergebnis: Erfolg, Fehler oder übersprungen.";
/** Tooltip für Spalte „Originaldateiname" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_ORIGINALDATEINAME =
"Ursprünglicher Dateiname der verarbeiteten PDF-Datei.";
/** Tooltip für Spalte „Neuer Dateiname" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_NEUER_DATEINAME =
"Von der KI vorgeschlagener, normierter Dateiname.";
/** Tooltip für Spalte „Datum" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_DATUM =
"Datum des Dokuments laut KI-Analyse.";
/** Tooltip für Spalte „Dauer" in der Verarbeitungslauf-Tabelle. */
public static final String BATCHRUN_COL_DAUER =
"Verarbeitungsdauer für diese Datei.";
// -------------------------------------------------------------------------
// Verlauf-Tab Detailbereich
// -------------------------------------------------------------------------
/** Tooltip für den KI-Begründungs-Bereich im Verlauf-Tab. */
public static final String VERLAUF_REASONING_AREA =
"KI-Begründung des ausgewählten Verarbeitungsversuchs.";
/** Tooltip für den Fehlerursachen-Bereich im Verlauf-Tab. */
public static final String VERLAUF_FAILURE_AREA =
"Fehlermeldung des letzten Fehler-Versuchs für dieses Dokument.";
/** Tooltip für Spalte „Status" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_STATUS =
"Aktueller Gesamtstatus des Dokuments.";
/** Tooltip für Spalte „Quelldatei" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_QUELLDATEI =
"Ursprünglicher Dateiname der PDF-Quelldatei.";
/** Tooltip für Spalte „Zieldatei" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_ZIELDATEI =
"Vom System erzeugter, normierter Dateiname im Zielordner.";
/** Tooltip für Spalte „Letzter Versuch" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_LETZTER_VERSUCH =
"Zeitpunkt des zuletzt abgeschlossenen Verarbeitungsversuchs.";
/** Tooltip für Spalte „Versuche" in der Übersichtstabelle des Verlauf-Tabs. */
public static final String VERLAUF_COL_VERSUCHE =
"Gesamtanzahl der Verarbeitungsversuche für dieses Dokument.";
/** Tooltip für Spalte „#" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_NR =
"Laufende Nummer des Verarbeitungsversuchs.";
/** Tooltip für Spalte „Datum" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_DATUM =
"Endzeitpunkt dieses Verarbeitungsversuchs.";
/** Tooltip für Spalte „Status" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_STATUS =
"Ergebnis dieses Verarbeitungsversuchs.";
/** Tooltip für Spalte „Provider" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_PROVIDER =
"KI-Provider, der für diesen Versuch verwendet wurde.";
/** Tooltip für Spalte „Modell" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_MODELL =
"Konkretes Sprachmodell, das für diesen Versuch verwendet wurde.";
/** Tooltip für Spalte „Vorgeschlagener Name" in der Versuche-Tabelle des Verlauf-Tabs. */
public static final String VERLAUF_VERSUCHE_COL_VORGESCHLAGENER_NAME =
"Vom System erzeugter Zieldateiname für diesen Versuch.";
// -------------------------------------------------------------------------
// Konfigurations-Tab Meldungsbereich und Modell-Neu-Laden
// -------------------------------------------------------------------------
/** Tooltip für den Button „Meldungen leeren". */
public static final String TOOLBAR_MELDUNGEN_LEEREN =
"Alle Meldungen im Meldungsbereich entfernen.";
/** Tooltip für den Button „Modelle neu laden". */
public static final String PROVIDER_MODELLE_NEU_LADEN =
"Verfügbare Modelle vom konfigurierten Provider neu abrufen.";
/** Tooltip für den Ordner-/Datei-Browser-Button. */
public static final String PFADE_BROWSER_BUTTON =
"Ordner oder Datei über den Datei-Dialog auswählen.";
// -------------------------------------------------------------------------
// Prompt-Tab Textbereich
// -------------------------------------------------------------------------
/** Tooltip für den Prompt-Textbereich im Prompt-Editor-Tab. */
public static final String PROMPT_TEXTAREA =
"KI-Anweisungstext. Dieser Prompt wird bei jedem Verarbeitungsversuch an das Sprachmodell gesendet.";
/** Tooltip für den Button „Speichern" im Prompt-Editor-Tab. */
public static final String PROMPT_SPEICHERN =
"Prompt-Datei speichern (atomar, UTF-8).";
/** Tooltip für den Button „Auf Standard zurücksetzen" im Prompt-Editor-Tab. */
public static final String PROMPT_ZURUECKSETZEN =
"Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern.";
/** Tooltip für den Button „Standard-Prompt erstellen" im Prompt-Editor-Tab. */
public static final String PROMPT_STANDARD_ANLEGEN =
"Standard-Prompt-Datei am konfigurierten Pfad anlegen.";
/** Nicht instanziierbar reine Konstantenklasse. */
private GuiTooltipTexts() {
throw new UnsupportedOperationException("Nicht instanziierbar");
}
}
@@ -4,8 +4,17 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuBar;
import javafx.scene.control.MenuItem;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
/**
* JavaFX application entry point for the PDF-Umbenenner GUI inbound adapter.
@@ -18,6 +27,13 @@ import javafx.stage.Stage;
* {@code titleUpdateListener} hook. The close-request handler is installed through
* {@link GuiConfigurationEditorWorkspace#installCloseRequestHandler(Stage)} so that
* unsaved changes are protected when the user tries to close the window.
*
* <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert.
* Über das Tray-Kontextmenü kann das Fenster wieder geöffnet oder die Anwendung beendet werden.
*
* <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 {
@@ -25,6 +41,11 @@ public class PdfUmbenennerGuiApplication extends Application {
private static final double DEFAULT_WIDTH = 1100;
private static final double DEFAULT_HEIGHT = 800;
private SystemTrayManager trayManager;
private GuiConfigurationEditorWorkspace workspace;
private GuiStartupContext guiStartupContext;
private GuiStatusRefreshTimeline refreshTimeline;
/**
* Creates a new instance of the JavaFX application.
*/
@@ -35,9 +56,12 @@ public class PdfUmbenennerGuiApplication extends Application {
/**
* Initializes and shows the primary stage.
* <p>
* Lädt die Anwendungs-Icons in allen verfügbaren Größen und setzt sie am Fenster.
* Wires the workspace title-update listener to the stage title so any dirty-state change
* causes an immediate window-title refresh. Also installs the close-request handler that
* guards unsaved changes before the window is closed.
* causes an immediate window-title refresh. Installs the close-request handler that
* guards unsaved changes and minimizes the window to the system tray instead of closing.
* <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}
*/
@@ -45,31 +69,179 @@ public class PdfUmbenennerGuiApplication extends Application {
public void start(Stage primaryStage) {
LOG.info("GUI: JavaFX-Oberfläche wird initialisiert.");
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
// Anwendungs-Icons laden; JavaFX wählt je nach Kontext automatisch die passende Größe
primaryStage.getIcons().addAll(
new Image(getClass().getResourceAsStream("/icons/Icon16.png")),
new Image(getClass().getResourceAsStream("/icons/Icon32.png")),
new Image(getClass().getResourceAsStream("/icons/Icon64.png")),
new Image(getClass().getResourceAsStream("/icons/Icon128.png"))
);
guiStartupContext = GuiStartupContextHolder.currentOrBlank();
workspace = new GuiConfigurationEditorWorkspace(guiStartupContext);
// Wire the title-update listener so the stage title stays in sync with the dirty state.
workspace.titleUpdateListener = primaryStage::setTitle;
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
// Statuszeile anlegen und mit dem Workspace verdrahten
GuiStatusBar statusBar = new GuiStatusBar(guiStartupContext.applicationVersion());
workspace.statusBarStateListener = statusBar::applyEditorState;
// Menüleiste mit Datenbank-Menü (Neue Datenbank anlegen")
MenuBar menuBar = buildMenuBar(workspace);
// Statuszeile unterhalb des Workspace-Inhalts einbetten
BorderPane outerLayout = new BorderPane();
outerLayout.setTop(menuBar);
outerLayout.setCenter(workspace.root());
outerLayout.setBottom(statusBar.root());
Scene scene = new Scene(outerLayout, DEFAULT_WIDTH, DEFAULT_HEIGHT);
primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
primaryStage.setScene(scene);
// Install the close-request handler that protects unsaved changes.
workspace.installCloseRequestHandler(primaryStage);
// System-Tray aktivieren: JavaFX-Runtime nicht beenden wenn Fenster versteckt wird
Platform.setImplicitExit(false);
trayManager = new SystemTrayManager(primaryStage);
if (trayManager.install()) {
installTrayCloseHandler(primaryStage, workspace);
}
// Scheduler-Close-Guard als äußerste Schicht: verhindert Beenden während Scheduler aktiv
installSchedulerCloseGuard(primaryStage);
primaryStage.setMaximized(true);
primaryStage.show();
// Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden.
workspace.autoLoadLastConfiguration();
// Zentrale Status-Refresh-Timeline starten (1 Hz)
refreshTimeline = new GuiStatusRefreshTimeline(
guiStartupContext.schedulerControlUseCase(),
this::refreshAllTabStates);
refreshTimeline.start();
LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
}
/**
* Called by the JavaFX runtime when the application is stopping.
* <p>
* Logs the GUI shutdown event. No additional cleanup is required.
* Stoppt die Status-Refresh-Timeline, entfernt das System-Tray-Icon und loggt das Beenden.
*/
@Override
public void stop() {
LOG.info("GUI: JavaFX-Anwendung wird beendet.");
if (refreshTimeline != null) {
refreshTimeline.stop();
}
if (trayManager != null) {
trayManager.remove();
}
}
/**
* Liest den aktuellen Scheduler-Status und aktualisiert alle betroffenen Tabs.
* <p>
* Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem JavaFX
* Application Thread aufgerufen. Wenn kein {@link SchedulerControlUseCase} vorhanden
* ist, wird der Aufruf ohne Fehler übersprungen.
*/
private void refreshAllTabStates() {
// Den Use Case nicht aus dem unveränderlichen GuiStartupContext lesen, sondern
// den zur Laufzeit (z. B. durch Auto-Load) verdrahteten Use Case verwenden.
// Andernfalls bliebe der Stop-Button dauerhaft deaktiviert, weil updateStatus
// nie aufgerufen würde.
workspace.refreshSchedulerStatus();
}
/**
* Baut die Menüleiste für das Hauptfenster auf.
* <p>
* Aktuell enthält sie genau einen Eintrag: das Menü Datenbank" mit der Aktion
* Neue Datenbank anlegen". Diese delegiert an
* {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}.
* <p>
* Der Menüpunkt ist deaktiviert, solange ein Verarbeitungslauf aktiv ist oder
* bereits eine DB-Anlage läuft. Die Reaktivierung erfolgt automatisch, sobald
* der Workspace die DB-Busy-Sperre wieder aufhebt.
*
* @param workspace der Workspace, an den die Aktionen delegieren; nie {@code null}
* @return die fertig konfigurierte Menüleiste
*/
private MenuBar buildMenuBar(GuiConfigurationEditorWorkspace workspace) {
Menu databaseMenu = new Menu("Datenbank");
MenuItem createNewItem = new MenuItem("Neue Datenbank anlegen…");
createNewItem.setOnAction(event -> workspace.requestCreateNewDatabase());
// Sperre während eines aktiven Verarbeitungslaufs oder einer laufenden DB-Anlage
createNewItem.disableProperty().bind(workspace.batchRunRunningProperty()
.or(workspace.dbBusyForDatabaseCreationProperty()));
databaseMenu.getItems().add(createNewItem);
return new MenuBar(databaseMenu);
}
/**
* Legt den Scheduler-Close-Guard als äußerste Schicht des Close-Request-Handlers an.
* <p>
* Ist kein {@link de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase}
* vorhanden, bleibt der bestehende Handler unverändert. Ist der Scheduler aktiv
* (Zustand != {@code STOPPED}), wird das Schließen verhindert und ein
* Informationsdialog angezeigt. Ist der Scheduler gestoppt, wird der bisherige
* Handler (SystemTray + Workspace-Dirty-Guard) aufgerufen.
*
* @param stage das primäre Fenster; darf nicht {@code null} sein
*/
private void installSchedulerCloseGuard(Stage stage) {
EventHandler<WindowEvent> existingHandler = stage.getOnCloseRequest();
stage.setOnCloseRequest(event -> {
// Use Case dynamisch über den Workspace lesen, weil der Scheduler erst
// nach erfolgreichem Datei-Öffnen (z. B. Auto-Load) verdrahtet wird und
// damit nicht zwingend im unveränderlichen GuiStartupContext steht.
if (workspace.isSchedulerActive()) {
event.consume();
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle("Anwendung kann nicht beendet werden");
alert.setHeaderText(null);
alert.setContentText(
"Ein Lauf ist aktiv oder der Scheduler läuft.\n"
+ "Bitte beende den Scheduler bzw. warte auf das Ende des Laufs.");
alert.showAndWait();
return;
}
if (existingHandler != null) {
existingHandler.handle(event);
}
});
}
/**
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
* System-Tray minimiert statt es zu schließen.
* <p>
* Der vom Workspace installierte Handler wird dabei vorrangig aufgerufen. Nur wenn
* er das Event nicht konsumiert (sauberer Zustand, keine laufenden Operationen),
* greift dieser Handler und versteckt das Fenster.
*
* @param stage das primäre Fenster
* @param workspace der Workspace-Handler, der bereits installiert wurde
*/
private void installTrayCloseHandler(Stage stage, GuiConfigurationEditorWorkspace workspace) {
EventHandler<WindowEvent> workspaceHandler = stage.getOnCloseRequest();
stage.setOnCloseRequest(event -> {
// Workspace-Handler zuerst: prüft Dirty-State, laufende Operationen usw.
if (workspaceHandler != null) {
workspaceHandler.handle(event);
}
// Wurde das Event nicht konsumiert, ist der Zustand sauber: Fenster in Tray verstecken
if (!event.isConsumed()) {
event.consume();
LOG.info("GUI: Fenster wird in den System-Tray minimiert.");
stage.hide();
}
});
}
}
@@ -0,0 +1,137 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.awt.AWTException;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import javax.imageio.ImageIO;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import javafx.application.Platform;
import javafx.stage.Stage;
/**
* Verwaltet das Windows System-Tray-Icon für den PDF-Umbenenner.
* <p>
* Wird das Hauptfenster geschlossen, bleibt die Anwendung im Hintergrund aktiv und zeigt
* ein Tray-Icon in der Windows-Taskleiste. Über das Kontextmenü kann das Fenster wieder
* geöffnet oder die Anwendung vollständig beendet werden.
* <p>
* Alle Stage-Operationen werden auf dem JavaFX Application Thread ausgeführt, da AWT-Events
* auf dem AWT Event Dispatch Thread eintreffen.
*/
class SystemTrayManager {
private static final Logger LOG = LogManager.getLogger(SystemTrayManager.class);
private final Stage stage;
private TrayIcon trayIcon;
private boolean installed;
/**
* Erstellt einen neuen {@code SystemTrayManager} für die angegebene Stage.
*
* @param stage das primäre Fenster; darf nicht {@code null} sein
*/
SystemTrayManager(Stage stage) {
this.stage = stage;
}
/**
* Installiert das System-Tray-Icon.
* <p>
* Schlägt die Installation fehl (System-Tray nicht unterstützt oder Icon-Bild nicht ladbar),
* wird {@code false} zurückgegeben und kein Tray-Icon angezeigt.
*
* @return {@code true} wenn das Icon erfolgreich installiert wurde, sonst {@code false}
*/
boolean install() {
if (!SystemTray.isSupported()) {
LOG.warn("GUI: System-Tray wird auf diesem System nicht unterstützt.");
return false;
}
BufferedImage image = loadTrayImage();
if (image == null) {
return false;
}
PopupMenu menu = buildContextMenu();
trayIcon = new TrayIcon(image, "PDF-Umbenenner", menu);
trayIcon.setImageAutoSize(true);
// Doppelklick öffnet das Fenster
trayIcon.addActionListener(e -> Platform.runLater(this::showWindow));
try {
SystemTray.getSystemTray().add(trayIcon);
installed = true;
LOG.info("GUI: System-Tray-Icon erfolgreich installiert.");
return true;
} catch (AWTException e) {
LOG.warn("GUI: System-Tray-Icon konnte nicht installiert werden: {}", e.getMessage(), e);
return false;
}
}
/**
* Entfernt das Tray-Icon aus dem System-Tray.
* Ist kein Icon installiert, wird der Aufruf ignoriert.
*/
void remove() {
if (installed && trayIcon != null) {
SystemTray.getSystemTray().remove(trayIcon);
installed = false;
LOG.info("GUI: System-Tray-Icon entfernt.");
}
}
/**
* Gibt an, ob das Tray-Icon aktiv installiert ist.
*
* @return {@code true} wenn das Icon im System-Tray sichtbar ist
*/
boolean isInstalled() {
return installed;
}
private BufferedImage loadTrayImage() {
try (InputStream stream = getClass().getResourceAsStream("/icons/Icon16.png")) {
if (stream == null) {
LOG.warn("GUI: Tray-Icon-Ressource '/icons/Icon16.png' nicht gefunden.");
return null;
}
return ImageIO.read(stream);
} catch (IOException e) {
LOG.warn("GUI: Tray-Icon-Bild konnte nicht geladen werden: {}", e.getMessage(), e);
return null;
}
}
private PopupMenu buildContextMenu() {
PopupMenu menu = new PopupMenu();
MenuItem openItem = new MenuItem("Öffnen");
openItem.addActionListener(e -> Platform.runLater(this::showWindow));
MenuItem exitItem = new MenuItem("Beenden");
exitItem.addActionListener(e -> {
remove();
Platform.exit();
System.exit(0);
});
menu.add(openItem);
menu.addSeparator();
menu.add(exitItem);
return menu;
}
private void showWindow() {
stage.show();
stage.toFront();
}
}
@@ -0,0 +1,119 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
/**
* Übersetzt strukturierte Fehlermeldungen aus der Anwendungsschicht in
* benutzerfreundliche deutsche Texte für die Darstellungsschicht der GUI.
* <p>
* Die Klasse wertet die englischsprachige Fehlermeldung aus dem Verarbeitungsversuch
* musterbasiert aus und liefert eine für den Endbenutzer lesbare Beschreibung des
* Fehlergrunds. Das ursprüngliche Datenmodell bleibt unverändert; die Übersetzung
* findet ausschließlich in der Darstellungsschicht statt.
* <p>
* Die Mustererkennung erfolgt ohne Berücksichtigung der Groß-/Kleinschreibung
* und prüft die definierten Schlüsselbegriffe in festgelegter Reihenfolge,
* damit spezifischere Muster vor allgemeineren greifen.
* <p>
* Die Klasse wird sowohl im Verarbeitungslauf-Tab als auch im Verlauf-Tab verwendet.
*/
public final class AiFailureMessageTranslator {
private AiFailureMessageTranslator() {
}
/**
* Liefert eine benutzerfreundliche deutsche Fehlermeldung für die angegebene
* technische Fehlerbeschreibung.
* <p>
* Ist {@code technicalMessage} {@code null} oder leer, wird der allgemeine
* Fallback-Text zurückgegeben.
*
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
* @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol
*/
public static String translate(String technicalMessage) {
if (technicalMessage == null || technicalMessage.isBlank()) {
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
}
String lower = technicalMessage.toLowerCase(java.util.Locale.ROOT);
// Pre-Check-Fehler: kein lesbarer Text im PDF
if (lower.contains("no usable text")) {
return "PDF enthält keinen lesbaren Text. Möglicherweise handelt es sich um einen Scan"
+ " ohne Texterkennung (OCR). Eine automatische Benennung ist nicht möglich.";
}
// KI-Validierungsfehler: Titel überschreitet die konfigurierte Maximallänge
if (lower.contains("title exceeds")) {
return buildTitleExceedsMessage(technicalMessage);
}
// Defekte oder strukturell nicht lesbare PDF-Datei
if (lower.contains("content not extractable")
|| lower.contains("ioexception")
|| lower.contains("end of file")
|| lower.contains("endoffileexception")
|| lower.contains("eof")) {
return "Die PDF-Datei ist ungültig oder beschädigt und kann nicht verarbeitet werden.";
}
// HTTP-Authentifizierungsfehler
if (lower.contains("http_401")) {
return "KI-Dienst: Ungültiger API-Schlüssel. Bitte in den Einstellungen prüfen.";
}
if (lower.contains("http_403")) {
return "KI-Dienst: Zugriff verweigert. Bitte API-Schlüssel und Berechtigungen prüfen.";
}
if (lower.contains("http_429")) {
return "KI-Dienst: Anfragelimit erreicht. Bitte später erneut versuchen.";
}
if (lower.contains("http_5")) {
return "KI-Dienst vorübergehend nicht erreichbar. Bitte später erneut versuchen.";
}
// Netzwerk- und Verbindungsfehler
if (lower.contains("connection") || lower.contains("timeout") || lower.contains("refused")) {
return "KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen.";
}
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
}
/**
* Baut aus einer Title exceeds"-Fehlermeldung einen benutzerfreundlichen Text,
* der Titel, tatsächliche Länge und konfiguriertes Limit nennt.
* <p>
* Erwartet wird das Format:
* {@code Title exceeds N characters (base title): 'Titel' }
* <p>
* Kann das Format nicht geparst werden, wird ein generischer Hinweis zurückgegeben.
*
* @param technicalMessage die vollständige technische Fehlermeldung
* @return benutzerfreundlicher Hinweis auf den zu langen Titel
*/
private static String buildTitleExceedsMessage(String technicalMessage) {
try {
int exceedsIdx = technicalMessage.indexOf("Title exceeds ");
if (exceedsIdx >= 0) {
String afterExceeds = technicalMessage.substring(exceedsIdx + "Title exceeds ".length());
int charIdx = afterExceeds.indexOf(" characters");
if (charIdx > 0) {
int limit = Integer.parseInt(afterExceeds.substring(0, charIdx).trim());
int colonQuote = technicalMessage.indexOf(": '", exceedsIdx);
if (colonQuote >= 0) {
String afterQuote = technicalMessage.substring(colonQuote + 3);
int closingQuote = afterQuote.lastIndexOf("'");
if (closingQuote > 0) {
String title = afterQuote.substring(0, closingQuote);
return "KI-Vorschlag abgelehnt: '" + title + "' ist zu lang ("
+ title.length() + " Zeichen, Limit: " + limit
+ "). Bitte Dateinamen manuell kürzen.";
}
}
}
}
} catch (NumberFormatException | StringIndexOutOfBoundsException ignored) {
// Fallback unten
}
return "KI-Vorschlag abgelehnt: Titel überschreitet die maximale Länge. Bitte Dateinamen manuell kürzen.";
}
}
@@ -0,0 +1,338 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
/**
* Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs
* die aggregierten Ergebnisse anzeigt.
*
* <p>Das Banner erscheint nach Laufabschluss unterhalb des Fortschrittsbalkens und
* oberhalb der Ergebnistabelle. Es zeigt nur Kategorien, deren Zähler größer als null
* ist. Folgende Status werden nicht gezählt und tauchen nie im Banner auf:
* {@code READY_FOR_AI}, {@code PROPOSAL_READY} und {@code PROCESSING} sind im
* Enum {@link DocumentCompletionStatus} nicht enthalten alle enthaltenen Werte
* werden gezählt, außer Einträgen mit {@code resetPending=true}, da diese keinen
* abgeschlossenen Zustand darstellen.
*
* <p>Farbe ist niemals das einzige Unterscheidungsmerkmal: Jedes Segment enthält
* ein Icon und einen Text.
*
* <p>Die öffentlichen Methoden {@link #clear()} und {@link #update(Map)} sind
* thread-agnostisch definiert, aber müssen auf dem JavaFX Application Thread aufgerufen
* werden (oder das Banner muss via {@code Platform.runLater} aktualisiert werden).
* Die Aggregations-Hilfsmethode {@link #aggregateCounts(Iterable)} ist vollständig
* unabhängig von JavaFX und kann auf jedem Thread aufgerufen werden.
*/
public final class BatchRunSummaryBanner {
/** Trennzeichen zwischen den Kategoriesegmenten. */
private static final String SEGMENT_SEPARATOR = " · ";
/** Abstand zwischen den Label-Segmenten in Pixeln. */
private static final int SPACING = 0;
/** Innerer Abstand des Containers in Pixeln (oben/unten). */
private static final double PADDING_V = 4.0;
/** Standardfarbe für den Summentext. */
private static final String STYLE_DEFAULT = "-fx-font-size: 12;";
/**
* Alle {@link DocumentCompletionStatus}-Werte, die im Banner angezeigt werden,
* in der verbindlichen Anzeigereihenfolge gemäß Spezifikation.
*/
private static final List<DocumentCompletionStatus> DISPLAYED_ORDER = List.of(
DocumentCompletionStatus.SUCCESS,
DocumentCompletionStatus.FAILED_RETRYABLE,
DocumentCompletionStatus.FAILED_PERMANENT,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE
);
/** Wurzel-Container des Banners wird in das Tab-Layout eingebettet. */
private final HBox container;
/**
* Vertikale Halterung fuer mehrzeilige Banner-Inhalte.
*
* <p>Enthaelt die Status-Zeile (bestehend), die Token-Zeile, die
* Kosten-Zeile und optional eine Cache-only-Zeile. Die Token-/Kosten-
* /Cache-Zeilen werden in V3.3 mit einem leeren {@link BatchRunTokenSummary}
* vorbelegt; die echten Aggregat-Werte werden durch das nachfolgende
* Arbeitspaket geliefert.
*/
private final VBox lineContainer;
/** Label, das den kompletten Bannertext als Inline-Segmente trägt. */
private final Label contentLabel;
/** Label fuer die Tokenzeile (Input/Output). */
private final Label tokenLabel;
/** Label fuer die Kosten-Zeile. */
private final Label costLabel;
/** Label fuer die optionale Cache-only-Hinweiszeile. */
private final Label cacheOnlyLabel;
/**
* Erstellt ein neues, initial unsichtbares Summary-Banner.
*/
public BatchRunSummaryBanner() {
contentLabel = new Label();
contentLabel.setStyle(STYLE_DEFAULT);
contentLabel.setWrapText(false);
tokenLabel = new Label();
tokenLabel.setStyle(STYLE_DEFAULT);
costLabel = new Label();
costLabel.setStyle(STYLE_DEFAULT);
cacheOnlyLabel = new Label();
cacheOnlyLabel.setStyle(STYLE_DEFAULT);
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
lineContainer = new VBox(2, contentLabel, tokenLabel, costLabel, cacheOnlyLabel);
lineContainer.setAlignment(Pos.CENTER_LEFT);
container = new HBox(SPACING, lineContainer);
container.setAlignment(Pos.CENTER_LEFT);
container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;");
// Initial unsichtbar, nimmt keinen Platz ein
container.setVisible(false);
container.setManaged(false);
}
// -------------------------------------------------------------------------
// Öffentliche API
// -------------------------------------------------------------------------
/**
* Versteckt das Banner und leert seinen Inhalt.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clear() {
contentLabel.setText("");
tokenLabel.setText("");
costLabel.setText("");
cacheOnlyLabel.setText("");
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
container.setVisible(false);
container.setManaged(false);
}
/**
* Aktualisiert das Banner mit den aggregierten Zählern und macht es sichtbar.
*
* <p>Zeigt nur Kategorien mit Anzahl &gt; 0. Wenn alle Zähler null sind (leerer Lauf),
* wird das Banner versteckt.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param counts Zuordnung von Verarbeitungsstatus zu Anzahl;
* fehlende Status werden als 0 interpretiert; darf nicht null sein
*/
public void update(Map<DocumentCompletionStatus, Integer> counts) {
update(counts, BatchRunTokenSummary.empty());
}
/**
* Aktualisiert das Banner mit Status-Zaehlern und Token-/Kosten-Aggregaten.
*
* <p>Zeigt die Status-Zeile (wenn nicht leer) sowie die Token- und Kosten-
* Zeilen. Die Cache-only-Zeile erscheint nur, wenn {@link
* BatchRunTokenSummary#cacheOnlyAttemptCount()} groesser als 0 ist.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param counts Zaehler je Status; nicht {@code null}
* @param tokenSummary Aggregat-Werte fuer Tokens und Kosten; nicht {@code null}
*/
public void update(Map<DocumentCompletionStatus, Integer> counts,
BatchRunTokenSummary tokenSummary) {
Objects.requireNonNull(counts, "counts darf nicht null sein");
Objects.requireNonNull(tokenSummary, "tokenSummary darf nicht null sein");
String text = buildBannerText(counts);
contentLabel.setText(text);
tokenLabel.setText(buildTokenLine(tokenSummary));
costLabel.setText(buildCostLine(tokenSummary));
if (tokenSummary.cacheOnlyAttemptCount() > 0) {
cacheOnlyLabel.setText(buildCacheOnlyLine(tokenSummary.cacheOnlyAttemptCount()));
cacheOnlyLabel.setVisible(true);
cacheOnlyLabel.setManaged(true);
} else {
cacheOnlyLabel.setText("");
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
}
if (text.isEmpty() && !tokenSummary.hasAnyData()) {
clear();
return;
}
container.setVisible(true);
container.setManaged(true);
}
/**
* Token-/Kosten-Aggregat fuer einen Banner-Eintrag.
*
* <p>Ein {@link #empty()}-Default reicht, solange das Read-Model fuer
* Aggregate noch nicht implementiert ist. AP-B liefert spaeter die echten
* Werte ueber den {@code TokenStatisticsReadModelPort}.
*
* @param totalInputTokens Summe Input-Tokens; ggf. {@code 0}
* @param totalOutputTokens Summe Output-Tokens; ggf. {@code 0}
* @param totalCostUsd Summe der Kosten in USD; ggf. {@code BigDecimal.ZERO}
* @param hasMissingPriceSnapshot {@code true}, wenn mind. ein Versuch ohne Preis-Snapshot vorlag
* @param hasCacheTokensIgnored {@code true}, wenn Cache-Tokens vorkamen
* @param cacheOnlyAttemptCount Anzahl Cache-only-Versuche im Lauf
*/
public record BatchRunTokenSummary(
long totalInputTokens,
long totalOutputTokens,
BigDecimal totalCostUsd,
boolean hasMissingPriceSnapshot,
boolean hasCacheTokensIgnored,
long cacheOnlyAttemptCount) {
/**
* Liefert ein leeres Aggregat (alle Zaehler null, Kosten 0).
*
* @return leeres Aggregat
*/
public static BatchRunTokenSummary empty() {
return new BatchRunTokenSummary(0L, 0L, BigDecimal.ZERO, false, false, 0L);
}
/**
* Pruefung, ob ueberhaupt Daten zum Anzeigen vorliegen.
*
* @return {@code true} bei Werten ungleich 0
*/
public boolean hasAnyData() {
return totalInputTokens > 0 || totalOutputTokens > 0
|| (totalCostUsd != null && totalCostUsd.signum() != 0)
|| cacheOnlyAttemptCount > 0;
}
}
private static String buildTokenLine(BatchRunTokenSummary s) {
return String.format(Locale.GERMAN, "Tokens: Input %,d Output %,d",
s.totalInputTokens(), s.totalOutputTokens());
}
private static String buildCostLine(BatchRunTokenSummary s) {
BigDecimal cost = s.totalCostUsd() != null ? s.totalCostUsd() : BigDecimal.ZERO;
BigDecimal rounded = cost.setScale(4, RoundingMode.HALF_UP);
StringBuilder sb = new StringBuilder("Kosten: $").append(rounded.toPlainString());
if (s.hasCacheTokensIgnored()) {
sb.append(" (ohne Cache-Anteil)");
}
if (s.hasMissingPriceSnapshot()) {
sb.append(" (unvollstaendig)");
}
return sb.toString();
}
private static String buildCacheOnlyLine(long count) {
return " " + count + " Cache-only Versuche (in Kosten nicht enthalten)";
}
/**
* Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout.
*
* @return der Container-Knoten; nie null
*/
public HBox getNode() {
return container;
}
// -------------------------------------------------------------------------
// Aggregations-Hilfe (thread-agnostisch, testbar ohne JavaFX)
// -------------------------------------------------------------------------
/**
* Zählt die Anzahl jedes {@link DocumentCompletionStatus} in der übergebenen
* Iterable. Einträge mit {@code resetPending=true} werden ignoriert, da sie
* keinen abgeschlossenen Verarbeitungszustand darstellen.
*
* <p>Diese Methode ist vollständig unabhängig von JavaFX und kann auf jedem
* Thread aufgerufen werden.
*
* @param rows die Ergebniszeilen des Laufs; darf nicht null sein;
* null-Elemente werden übersprungen
* @return eine Map mit der Anzahl je Status; enthält alle anzuzeigenden
* Status (fehlende haben Wert 0); nie null
*/
public static Map<DocumentCompletionStatus, Integer> aggregateCounts(
Iterable<? extends GuiBatchRunResultRow> rows) {
Objects.requireNonNull(rows, "rows darf nicht null sein");
Map<DocumentCompletionStatus, Integer> counts = new EnumMap<>(DocumentCompletionStatus.class);
// Alle anzuzeigenden Status mit 0 vorbelegen
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
counts.put(status, 0);
}
for (GuiBatchRunResultRow row : rows) {
if (row == null) {
continue;
}
// Reset-Pending-Zeilen zählen nicht sie haben noch keinen abgeschlossenen Status
if (row.resetPending()) {
continue;
}
DocumentCompletionStatus status = row.status();
// Nur anzuzeigende Status zählen (entspricht dem Ausschluss von
// Übergangszuständen wie READY_FOR_AI, PROPOSAL_READY, PROCESSING)
if (counts.containsKey(status)) {
counts.merge(status, 1, Integer::sum);
}
}
return counts;
}
// -------------------------------------------------------------------------
// Interne Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Erzeugt den angezeigten Bannertext aus den Zählern.
* Liefert einen leeren String wenn alle Zähler null sind.
*
* @param counts die Zähler je Status; darf nicht null sein
* @return der fertige Bannertext oder ein leerer String
*/
static String buildBannerText(Map<DocumentCompletionStatus, Integer> counts) {
List<String> segments = new ArrayList<>();
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
int count = counts.getOrDefault(status, 0);
if (count > 0) {
String icon = ProcessingStatusPresentation.iconFor(status);
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
segments.add(icon + " " + count + " " + category);
}
}
return String.join(SEGMENT_SEPARATOR, segments);
}
}
@@ -0,0 +1,495 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
/**
* Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
* Ergebnis-Zeile.
* <p>
* Die Komponente kapselt Eingabefeld, Validierungsanzeige sowie die
* Schaltflächen Dateiname übernehmen" und „Zurücksetzen auf KI-Vorschlag". Sie kennt
* drei Zustände gemäß fachlicher Spezifikation:
* <ul>
* <li><b>KI-Vorschlag</b> der ursprünglich generierte Name; unveränderlich pro Zeile.</li>
* <li><b>Letzter gespeicherter Name</b> der zuletzt bestätigte Name; entspricht dem
* aktuellen Stand in Dateisystem und Persistenz.</li>
* <li><b>Aktuelle Eingabe</b> der im Textfeld sichtbare Wert; kann vom letzten
* gespeicherten Namen abweichen (Dirty-State).</li>
* </ul>
*
* <h2>Threading</h2>
* <p>
* Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen werden.
* Die tatsächliche Speicher-Operation ist in der Verantwortung des aufrufenden Tabs und
* läuft dort auf einem Hintergrund-Worker-Thread.
*/
public final class FileNameEditorPane {
/** Feste PDF-Erweiterung für Zieldateien. */
public static final String PDF_EXTENSION = ".pdf";
/** Windows-Maximal-Pfadlänge (MAX_PATH = 260 inkl. Null-Terminator = 259 nutzbar). */
public static final int MAX_WINDOWS_PATH_LENGTH = 259;
private static final Set<String> RESERVED_WINDOWS_NAMES = buildReservedWindowsNames();
private static final String FORBIDDEN_CHARS_REGEX = ".*[\\\\/:*?\"<>|].*";
private final VBox root = new VBox(4);
private final TextField textField = new TextField();
private final Label validationLabel = new Label();
private final Button saveButton = new Button("Dateiname übernehmen");
private final Button resetButton = new Button("Zurücksetzen auf KI-Vorschlag");
private final Label sectionTitle = new Label("Dateiname");
private Optional<String> aiProposal = Optional.empty();
private Optional<String> lastSavedName = Optional.empty();
private String targetFolderPath = "";
private boolean selectionEditable = false;
private boolean globalEnabled = true;
private boolean suppressValidation = false;
private Consumer<String> onSaveRequested = name -> { };
/**
* Erstellt die Komponente mit leerem und deaktiviertem Zustand.
*/
public FileNameEditorPane() {
sectionTitle.setStyle("-fx-font-weight: bold;");
textField.setId("filename-editor-text-field");
Tooltip textFieldTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_TEXTFELD);
textFieldTooltip.setShowDelay(Duration.millis(300));
textField.setTooltip(textFieldTooltip);
HBox.setHgrow(textField, Priority.ALWAYS);
HBox inputRow = new HBox(4, textField);
inputRow.setAlignment(Pos.CENTER_LEFT);
validationLabel.setId("filename-editor-validation-label");
validationLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #c62828;");
validationLabel.setVisible(false);
validationLabel.setManaged(false);
validationLabel.setWrapText(true);
saveButton.setId("filename-editor-save-button");
saveButton.setOnAction(e -> fireSaveRequest());
Tooltip saveTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
saveTooltip.setShowDelay(Duration.millis(300));
saveButton.setTooltip(saveTooltip);
resetButton.setId("filename-editor-reset-button");
resetButton.setOnAction(e -> resetToAiProposal());
Tooltip resetTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
resetTooltip.setShowDelay(Duration.millis(300));
resetButton.setTooltip(resetTooltip);
HBox buttonRow = new HBox(8, saveButton, resetButton);
buttonRow.setAlignment(Pos.CENTER_LEFT);
buttonRow.setPadding(new Insets(4, 0, 0, 0));
root.getChildren().addAll(sectionTitle, inputRow, validationLabel, buttonRow);
root.setPadding(new Insets(0, 0, 4, 0));
// Live-Validierung auf jeden Tastendruck.
textField.textProperty().addListener((obs, oldText, newText) -> {
if (!suppressValidation) {
refreshUiState();
}
});
// Enter löst Speichern aus, Escape setzt auf lastSavedName zurück.
textField.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
if (!saveButton.isDisabled()) {
fireSaveRequest();
event.consume();
}
} else if (event.getCode() == KeyCode.ESCAPE) {
discardChanges();
event.consume();
}
});
clearSelection();
}
/**
* Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
*
* @return das Root-Control der Komponente; nie null
*/
public Region getNode() {
return root;
}
/**
* Registriert einen Callback, der ausgelöst wird, wenn der Benutzer Dateiname übernehmen"
* anfordert. Parameter ist der gewünschte Basisname ohne {@code .pdf}-Erweiterung.
*
* @param callback Callback; darf nicht null sein (leerer Consumer als No-Op möglich)
*/
public void setOnSaveRequested(Consumer<String> callback) {
this.onSaveRequested = Objects.requireNonNull(callback, "callback must not be null");
}
/**
* Aktualisiert den Zustand für die neu selektierte Zeile.
* <p>
* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet,
* der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}.
* Editierbarkeitsregeln:
* <ul>
* <li>{@code resetPending} nicht editierbar.</li>
* <li>{@code SUCCESS} und {@code SKIPPED_ALREADY_PROCESSED} editierbar, sofern
* ein bisher gespeicherter Zieldateiname vorliegt (Umbenennen einer existierenden
* Zieldatei).</li>
* <li>{@code FAILED_RETRYABLE}, {@code FAILED_PERMANENT} und
* {@code SKIPPED_FINAL_FAILURE} editierbar; das Eingabefeld erlaubt die
* Eingabe eines manuellen Zieldateinamens auch dann, wenn (noch) kein
* Vorschlag oder gespeicherter Name vorliegt (Kopieren der Quelldatei
* mit manuellem Namen).</li>
* </ul>
*
* @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()}
* @param targetFolderPath Zielordner-Pfad für die Pfadlängen-Validierung; darf
* {@code null} sein (wird als leer behandelt)
*/
public void loadSelection(GuiBatchRunResultRow row, String targetFolderPath) {
this.targetFolderPath = targetFolderPath == null ? "" : targetFolderPath;
if (row == null) {
clearSelection();
return;
}
this.aiProposal = stripPdfExtension(row.finalFileName());
this.lastSavedName = stripPdfExtension(row.effectiveFileName());
boolean editable;
if (row.resetPending()) {
editable = false;
} else if (requiresExistingTargetForRename(row.status())) {
// Umbenennen einer existierenden Zieldatei: nur sinnvoll, wenn ein
// gespeicherter Name vorliegt.
editable = lastSavedName.isPresent();
} else {
// Manuelle Kopie: das Feld ist auch ohne gespeicherten Namen editierbar.
editable = isRowEditable(row);
}
this.selectionEditable = editable;
suppressValidation = true;
try {
textField.setText(lastSavedName.orElse(""));
} finally {
suppressValidation = false;
}
refreshUiState();
}
/**
* Liefert {@code true}, wenn die Zeile einen Status hat, bei dem die Editierung
* eine bestehende Zieldatei umbenennt (im Gegensatz zur Kopie der Quelldatei).
*
* @param status der aggregierte Abschlussstatus der Zeile
* @return {@code true} für SUCCESS und SKIPPED_ALREADY_PROCESSED; sonst {@code false}
*/
private static boolean requiresExistingTargetForRename(DocumentCompletionStatus status) {
return status == DocumentCompletionStatus.SUCCESS
|| status == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED;
}
/**
* Leert die Komponente und deaktiviert die Eingabe. Wird aufgerufen wenn keine Zeile
* selektiert ist.
*/
public void clearSelection() {
this.aiProposal = Optional.empty();
this.lastSavedName = Optional.empty();
this.selectionEditable = false;
suppressValidation = true;
try {
textField.setText("");
} finally {
suppressValidation = false;
}
refreshUiState();
}
/**
* Setzt den Textfeldinhalt auf den zuletzt gespeicherten Namen zurück. Äquivalent zum
* Drücken der Escape-Taste im Textfeld.
*/
public void discardChanges() {
suppressValidation = true;
try {
textField.setText(lastSavedName.orElse(""));
} finally {
suppressValidation = false;
}
refreshUiState();
}
/**
* Setzt den Textfeldinhalt auf den KI-Vorschlag zurück. Es erfolgt <em>kein</em>
* Speichervorgang der Benutzer kann anschließend über Dateiname übernehmen"
* bestätigen.
*/
public void resetToAiProposal() {
if (aiProposal.isEmpty()) {
return;
}
suppressValidation = true;
try {
textField.setText(aiProposal.get());
} finally {
suppressValidation = false;
}
refreshUiState();
}
/**
* Aktiviert oder deaktiviert die gesamte Komponente. Während eines laufenden Batch-Laufs
* soll die Komponente deaktiviert sein.
*
* @param enabled {@code true} wenn Bedienung erlaubt ist
*/
public void setEnabled(boolean enabled) {
this.globalEnabled = enabled;
refreshUiState();
}
/**
* Liefert {@code true} wenn die aktuelle Texteingabe vom letzten gespeicherten Namen
* abweicht.
*
* @return ob ungespeicherte Änderungen im Textfeld vorliegen
*/
public boolean isDirty() {
if (!selectionEditable) {
return false;
}
String current = textField.getText() == null ? "" : textField.getText();
String saved = lastSavedName.orElse("");
return !current.equals(saved);
}
/**
* Setzt den Dirty-State zurück, ohne das Textfeld neu zu laden. Wird aufgerufen,
* nachdem eine Umbenennung erfolgreich abgeschlossen wurde, damit ein anschließendes
* Ersetzen der Tabellenzeile keinen Verwerfen-Dialog auslöst. Der angezeigte Text
* im Textfeld bleibt unverändert; {@code lastSavedName} wird auf den aktuellen
* Textfeldinhalt gesetzt.
*/
public void clearDirtyState() {
String current = textField.getText() == null ? "" : textField.getText();
this.lastSavedName = current.isBlank() ? Optional.empty() : Optional.of(current);
refreshUiState();
}
/**
* Liefert {@code true} wenn für die aktuelle Zeile ein KI-Vorschlag vorliegt.
*
* @return ob ein KI-Vorschlag existiert
*/
public boolean hasAiProposal() {
return aiProposal.isPresent();
}
/**
* Liefert {@code true} wenn für die aktuelle Zeile ein zuletzt gespeicherter Name
* existiert.
*
* @return ob ein letzter gespeicherter Name existiert
*/
public boolean hasLastSaved() {
return lastSavedName.isPresent();
}
/**
* Aktualisiert intern den letzten gespeicherten Namen. Typisch nach erfolgreichem
* Speichervorgang im Tab (ohne erneut {@link #loadSelection(GuiBatchRunResultRow, String)}
* aufzurufen).
*
* @param newLastSavedName neuer letzter gespeicherter Name ohne {@code .pdf}; darf
* {@code null} sein
*/
public void updateLastSavedName(String newLastSavedName) {
this.lastSavedName = newLastSavedName == null || newLastSavedName.isBlank()
? Optional.empty()
: Optional.of(newLastSavedName);
suppressValidation = true;
try {
textField.setText(lastSavedName.orElse(""));
} finally {
suppressValidation = false;
}
refreshUiState();
}
// --- Test-Accessoren ------------------------------------------------------
/** Visible for tests. */
TextField textField() {
return textField;
}
/** Visible for tests. */
Label validationLabel() {
return validationLabel;
}
/** Visible for tests. */
Button saveButton() {
return saveButton;
}
/** Visible for tests. */
Button resetButton() {
return resetButton;
}
// --- Interne Helfer -------------------------------------------------------
private void fireSaveRequest() {
if (saveButton.isDisabled()) {
return;
}
String current = textField.getText() == null ? "" : textField.getText();
onSaveRequested.accept(current);
}
private void refreshUiState() {
boolean enabled = selectionEditable && globalEnabled;
textField.setDisable(!enabled);
// Button Zurücksetzen auf KI-Vorschlag" ist nur aktiv, wenn Eingabe möglich
// und ein KI-Vorschlag vorliegt.
resetButton.setDisable(aiProposal.isEmpty() || !enabled);
if (!enabled) {
// Validierung und Speichern-Button unterdrücken, Rahmen neutral.
validationLabel.setVisible(false);
validationLabel.setManaged(false);
textField.setStyle("");
saveButton.setDisable(true);
return;
}
String current = textField.getText() == null ? "" : textField.getText();
Optional<String> error = validate(current);
if (error.isPresent()) {
validationLabel.setText(error.get());
validationLabel.setVisible(true);
validationLabel.setManaged(true);
textField.setStyle("-fx-border-color: #c62828; -fx-border-width: 1.5;");
saveButton.setDisable(true);
} else {
validationLabel.setVisible(false);
validationLabel.setManaged(false);
if (isDirty()) {
// Dirty-Markierung: orangefarbener Rand.
textField.setStyle("-fx-border-color: #e65100; -fx-border-width: 1.5;");
saveButton.setDisable(false);
} else {
textField.setStyle("");
saveButton.setDisable(true);
}
}
}
/**
* Führt die vollständige Dateinamen-Validierung aus und liefert gegebenenfalls den
* fachlichen Fehlertext. Paket-privat für Unit-Tests.
*
* @param input Eingabe aus dem Textfeld (ohne {@code .pdf})
* @return der Fehlertext oder {@link Optional#empty()} wenn gültig
*/
Optional<String> validate(String input) {
if (input == null || input.isBlank()) {
return Optional.of("Dateiname darf nicht leer sein");
}
if (!input.equals(input.strip())) {
return Optional.of("Leerzeichen am Anfang oder Ende nicht erlaubt");
}
if (input.matches(FORBIDDEN_CHARS_REGEX)) {
return Optional.of("Unerlaubtes Zeichen (nicht erlaubt: \\ / : * ? \" < > |)");
}
if (RESERVED_WINDOWS_NAMES.contains(input.toUpperCase(java.util.Locale.ROOT))) {
return Optional.of("Reservierter Systemname");
}
if (input.endsWith(".")) {
return Optional.of("Dateiname darf nicht auf einen Punkt enden");
}
int totalLength = pathLengthEstimate(input);
if (totalLength > MAX_WINDOWS_PATH_LENGTH) {
return Optional.of("Dateipfad zu lang (Windows-Limit " + MAX_WINDOWS_PATH_LENGTH
+ " Zeichen, aktuell " + totalLength + ")");
}
return Optional.empty();
}
private int pathLengthEstimate(String baseName) {
String folder = targetFolderPath == null ? "" : targetFolderPath;
int folderLength = folder.length();
int separatorLength = folderLength == 0 ? 0 : 1;
return folderLength + separatorLength + baseName.length() + PDF_EXTENSION.length();
}
/**
* Liefert {@code true}, wenn die Zeile fachlich für eine manuelle Dateinamens-Aktion
* editierbar ist.
* <p>
* Editierbar sind alle nicht-resetpending-Zeilen unabhängig davon, ob die Aktion
* eine Zieldatei umbenennt (SUCCESS, SKIPPED_ALREADY_PROCESSED) oder die Quelldatei
* kopiert (FAILED_*, SKIPPED_FINAL_FAILURE). Die genaue Aktion wird vom Tab anhand
* des Status entschieden.
*
* @param row die Zeile, deren Editierbarkeit geprüft werden soll
* @return {@code true} wenn die Zeile editierbar ist; sonst {@code false}
*/
private static boolean isRowEditable(GuiBatchRunResultRow row) {
return !row.resetPending();
}
private static Optional<String> stripPdfExtension(Optional<String> fileNameWithExtension) {
if (fileNameWithExtension.isEmpty()) {
return Optional.empty();
}
String raw = fileNameWithExtension.get();
if (raw.toLowerCase(java.util.Locale.ROOT).endsWith(PDF_EXTENSION)) {
return Optional.of(raw.substring(0, raw.length() - PDF_EXTENSION.length()));
}
return Optional.of(raw);
}
private static Set<String> buildReservedWindowsNames() {
Set<String> reserved = new HashSet<>();
reserved.add("CON");
reserved.add("PRN");
reserved.add("AUX");
reserved.add("NUL");
for (int i = 1; i <= 9; i++) {
reserved.add("COM" + i);
reserved.add("LPT" + i);
}
return Set.copyOf(reserved);
}
}
@@ -0,0 +1,769 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import javafx.application.Platform;
import javafx.scene.control.Alert;
/**
* Coordinates a single batch run (regular or targeted mini-run) triggered from the
* JavaFX GUI, and optional reset-only operations on selected document fingerprints.
* <p>
* The coordinator owns the background worker thread that executes the run, maintains the
* cancellation flag, and translates the
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
* callbacks into a GUI-friendly event stream on the JavaFX Application Thread.
*
* <h2>Threading</h2>
* <ul>
* <li>The batch run and reset operations execute on a daemon worker thread created by
* {@link #threadFactory}. No JavaFX code touches this thread.</li>
* <li>Every GUI callback ({@link Listener}) is invoked on the JavaFX Application Thread
* via {@link Platform#runLater(Runnable)}, so listeners may freely mutate
* {@code Control}s without taking any further precautions.</li>
* <li>{@link #requestCancellation()} sets a volatile flag that the use case polls
* between candidates (soft-stop). It never interrupts the worker thread; the
* currently-processed candidate always completes in full.</li>
* </ul>
*
* <h2>Lifecycle</h2>
* <ol>
* <li>Construct with a regular launcher, a mini-run launcher, a reset port, a thread
* factory and a listener.</li>
* <li>Call {@link #start(Path)} to begin a regular run, or
* {@link #startMiniRun(Path, Set)} for a targeted mini-run, or
* {@link #startReset(Path, Set)} for a status-reset-only operation.</li>
* <li>Optionally call {@link #requestCancellation()} to trigger soft-stop for runs.</li>
* <li>Wait for {@link Listener#onRunEnded(RunSummary, GuiBatchRunLaunchOutcome)} or
* {@link Listener#onResetCompleted(ResetDocumentStatusResult)} on the FX thread.</li>
* <li>Start a new operation only after the previous one has ended.</li>
* </ol>
*/
public final class GuiBatchRunCoordinator {
private static final String CONFIG_FILE_NOT_NULL = "configFilePath must not be null";
private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
private static final String WORKER_THREAD_NAME = "gui-batch-run";
/**
* Listener interface invoked on the JavaFX Application Thread during a run or reset.
*/
public interface Listener {
/**
* Invoked once, after the batch use case has scanned the source folder and knows
* the total candidate count.
*
* @param runId the identifier of the run; never {@code null}
* @param totalCandidates the number of candidates detected in the source folder;
* never negative
*/
void onRunStarted(RunId runId, int totalCandidates);
/**
* Invoked once per candidate whose processing reached a terminal resolution.
*
* @param row the row describing the candidate result; never {@code null}
*/
void onDocumentCompleted(GuiBatchRunResultRow row);
/**
* Invoked once after the run has fully terminated on the worker thread.
*
* @param summary the final outcome counts; never {@code null}
* @param outcome a description of how the run terminated; never {@code null}
*/
void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);
/**
* Invoked once after a reset-only operation has completed on the worker thread.
* <p>
* The default implementation does nothing so existing {@link Listener}
* implementations need not override this method until they need reset
* notifications.
*
* @param result the full outcome of the reset operation; never {@code null}
*/
default void onResetCompleted(ResetDocumentStatusResult result) {
// no-op default
}
}
private final GuiBatchRunLauncher launcher;
private final GuiMiniRunLauncher miniRunLauncher;
private final GuiResetDocumentStatusPort resetPort;
private final Function<Runnable, Thread> threadFactory;
private final Consumer<Runnable> fxDispatcher;
private final Listener listener;
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
private final Optional<ConfigurationFileLockPort> configurationFileLockPort;
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
/**
* Creates the coordinator with the default worker-thread factory and the default
* JavaFX Application Thread dispatcher.
* <p>
* Mini-run and reset capabilities are unavailable; all such requests will return
* {@code false}.
*
* @param launcher bridge to Bootstrap used to execute the batch; must not be null
* @param listener GUI listener invoked on the FX thread; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, Listener listener) {
this(launcher,
rejectingMiniRunLauncher(),
rejectingResetPort(),
defaultThreadFactory(),
defaultFxDispatcher(),
listener);
}
/**
* Creates the coordinator with all ports and the default worker-thread factory and
* JavaFX Application Thread dispatcher.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param listener GUI listener invoked on the FX thread; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Listener listener) {
this(launcher, miniRunLauncher, resetPort,
defaultThreadFactory(), defaultFxDispatcher(), listener);
}
/**
* Creates the coordinator with all ports and the historical file name port, using the
* default worker-thread factory and JavaFX Application Thread dispatcher.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param listener GUI listener invoked on the FX thread; must not be null
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
* skipped documents; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
this(launcher, miniRunLauncher, resetPort,
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort);
}
/**
* Creates the coordinator with all ports and the configuration file lock port, using
* the default worker-thread factory and JavaFX Application Thread dispatcher.
* <p>
* This constructor is intended for production wiring in {@code GuiBatchRunTab} where
* the lock port is supplied by Bootstrap.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param listener GUI listener invoked on the FX thread; must not be null
* @param historicalDocumentContextPort port for resolving historical context; must not be null
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
* acquired before each run; {@code null} is treated as empty
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
this(launcher, miniRunLauncher, resetPort,
defaultThreadFactory(), defaultFxDispatcher(), listener,
historicalDocumentContextPort, configurationFileLockPort);
}
/**
* Creates the coordinator with custom hooks for the worker-thread factory and the
* UI-thread dispatcher.
* <p>
* Tests use this constructor to execute batches synchronously or to verify which
* thread UI callbacks run on, without depending on an actual JavaFX runtime being
* initialised.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param threadFactory factory returning a ready-to-start worker thread; must not
* be null
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
* Thread; must not be null
* @param listener GUI listener; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener) {
this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
noOpHistoricalDocumentContextPort());
}
/**
* Creates the coordinator with all ports, custom thread factory, FX dispatcher,
* historical file name port, and an optional configuration file lock port.
* <p>
* This is the canonical constructor. All other constructors delegate here.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param threadFactory factory returning a ready-to-start worker thread; must not
* be null
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
* Thread; must not be null
* @param listener GUI listener; must not be null
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
* skipped documents; must not be null
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
* acquired before each run and released in a finally block;
* {@code null} is treated as empty
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
this.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
this.listener = Objects.requireNonNull(listener, "listener must not be null");
this.historicalDocumentContextPort = Objects.requireNonNull(
historicalDocumentContextPort, "historicalDocumentContextPort must not be null");
this.configurationFileLockPort =
Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
}
/**
* Backward-compatible constructor that omits the configuration file lock port.
* <p>
* Preserves existing callers that were written before the lock port was added.
* Delegates to the canonical constructor with {@code configurationFileLockPort} empty.
*
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
* not be null
* @param threadFactory factory returning a ready-to-start worker thread; must not
* be null
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
* Thread; must not be null
* @param listener GUI listener; must not be null
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
* skipped documents; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetPort,
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener,
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
historicalDocumentContextPort, Optional.empty());
}
/**
* Legacy constructor retained for backward compatibility with tests that do not
* require mini-run or reset capabilities.
*
* @param launcher bridge to Bootstrap; must not be null
* @param threadFactory factory returning a ready-to-start worker thread; must not
* be null
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
* Thread; must not be null
* @param listener GUI listener; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener) {
this(launcher,
rejectingMiniRunLauncher(),
rejectingResetPort(),
threadFactory,
fxDispatcher,
listener);
}
/**
* Returns whether a run or reset is currently active.
*
* @return {@code true} while a worker thread is executing
*/
public boolean isRunning() {
Thread worker = activeWorker.get();
return worker != null && worker.isAlive();
}
/**
* Starts a new regular run for the supplied configuration file.
* <p>
* Immediately returns once the worker thread has been started. All further progress
* is communicated through the configured {@link Listener} on the JavaFX Application
* Thread. An attempt to start a new run while another is still active is rejected
* with {@code false} and leaves the currently running batch untouched.
*
* @param configFilePath the configuration file the run shall read from; must not be
* {@code null}
* @return {@code true} when a new worker thread was started, {@code false} when a run
* was already in progress
* @throws NullPointerException if {@code configFilePath} is {@code null}
*/
public boolean start(Path configFilePath) {
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
if (isRunning()) {
return false;
}
cancellationRequested.set(false);
Runnable task = () -> executeRun(configFilePath);
return startWorker(task);
}
/**
* Starts a targeted mini-run for the supplied fingerprint filter.
* <p>
* The worker thread first delegates to the {@link GuiMiniRunLauncher} which applies
* the full processing pipeline to only the specified documents. Progress callbacks
* are forwarded to the {@link Listener} on the JavaFX Application Thread in the same
* way as for a regular run.
*
* @param configFilePath the configuration file; must not be {@code null}
* @param fingerprintFilter the set of document fingerprints to process; must not be
* {@code null}
* @return {@code true} when a new worker thread was started, {@code false} when a run
* was already in progress
* @throws NullPointerException if any argument is {@code null}
*/
public boolean startMiniRun(Path configFilePath,
Set<DocumentFingerprint> fingerprintFilter) {
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
if (isRunning()) {
return false;
}
cancellationRequested.set(false);
Runnable task = () -> executeMiniRun(configFilePath, fingerprintFilter);
return startWorker(task);
}
/**
* Starts a reprocessing operation: resets the database status of the specified
* fingerprints and immediately launches a targeted mini-run for them.
* <p>
* This method is the preferred entry point for "Erneut verarbeiten" (reprocess)
* actions in the GUI. It ensures that documents marked as FAILED_FINAL or otherwise
* ineligible for processing are reset before the mini-run begins, so they are
* reprocessed rather than skipped.
* <p>
* The reset executes synchronously on the caller's thread before the worker thread
* is started. This guarantees that the mini-run sees the documents in a
* reprocessable state.
*
* @param configFilePath the configuration file; must not be {@code null}
* @param fingerprintFilter the set of document fingerprints to reset and process;
* must not be {@code null}
* @return {@code true} when a new worker thread was started, {@code false} when a run
* was already in progress or when the reset failed for all fingerprints
* @throws NullPointerException if any argument is {@code null}
*/
public boolean startReprocessing(Path configFilePath,
Set<DocumentFingerprint> fingerprintFilter) {
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
if (isRunning()) {
return false;
}
// Reset the database status synchronously before starting the mini-run.
// This ensures that documents are not skipped due to FAILED_FINAL or other
// terminal states.
LOG.info("GUI-Erneut-Verarbeiten: Starte Status-Reset für {} Dokument(e), Konfiguration={}.",
fingerprintFilter.size(), configFilePath);
ResetDocumentStatusResult resetResult = resetPort.reset(configFilePath, fingerprintFilter);
LOG.info("GUI-Erneut-Verarbeiten: Status-Reset abgeschlossen {} erfolgreich, {} fehlgeschlagen.",
resetResult.successCount(), resetResult.failureCount());
if (resetResult.successCount() == 0) {
LOG.warn("GUI-Reprocessing: Reset für alle {} Dokumente fehlgeschlagen; "
+ "Mini-Lauf wird nicht gestartet.", fingerprintFilter.size());
return false;
}
LOG.info("GUI-Reprocessing: {} von {} Dokumenten erfolgreich zurückgesetzt.",
resetResult.successCount(), resetResult.requestedCount());
// Now start the mini-run with the reset fingerprints.
return startMiniRun(configFilePath, fingerprintFilter);
}
/**
* Starts a reset-only operation for the supplied fingerprint set.
* <p>
* The worker thread calls the {@link GuiResetDocumentStatusPort} to delete all
* persistence data for the specified fingerprints. No reprocessing run is triggered.
* On completion the {@link Listener#onResetCompleted(ResetDocumentStatusResult)} callback
* is invoked on the JavaFX Application Thread.
*
* @param configFilePath the configuration file that identifies the database; must not
* be {@code null}
* @param fingerprints the set of document fingerprints to reset; must not be
* {@code null}
* @return {@code true} when a new worker thread was started, {@code false} when a run
* was already in progress
* @throws NullPointerException if any argument is {@code null}
*/
public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
if (isRunning()) {
return false;
}
// Reset does not support cancellation; set the flag to false so the
// running state is consistent with the pattern used by run operations.
cancellationRequested.set(false);
Runnable task = () -> executeReset(configFilePath, fingerprints);
return startWorker(task);
}
/**
* Requests soft-stop cancellation of the currently running batch or mini-run.
* <p>
* The flag is honoured between candidates the candidate that is currently being
* processed is always completed in full and persisted before the run ends. Calling
* this method when no run is active has no effect. Reset operations ignore this flag.
*/
public void requestCancellation() {
if (isRunning()) {
cancellationRequested.set(true);
}
}
/**
* Returns whether cancellation has been requested for the current (or last) run.
*
* @return {@code true} when a cancellation request is pending or was pending when
* the last run ended; {@code false} before the first run
*/
public boolean isCancellationRequested() {
return cancellationRequested.get();
}
// -------------------------------------------------------------------------
// Worker helpers
// -------------------------------------------------------------------------
private boolean startWorker(Runnable task) {
Thread worker = threadFactory.apply(task);
Objects.requireNonNull(worker, "threadFactory must not return null");
activeWorker.set(worker);
worker.start();
return true;
}
private void executeRun(Path configFilePath) {
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
configFilePath);
observerSummary.set(null);
if (configurationFileLockPort.isPresent()) {
try {
configurationFileLockPort.get().acquireLock();
} catch (ConfigurationFileLockException e) {
LOG.warn("GUI-Verarbeitungslauf: Konfigurationsdatei gesperrt Lauf abgebrochen: {}",
e.getMessage());
fxDispatcher.accept(() -> showLockErrorAlert());
finishRun(GuiBatchRunLaunchOutcome.rejected(
"Konfigurationsdatei gesperrt Lauf wurde abgebrochen."));
return;
}
}
try {
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
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) {
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
observerSummary.set(null);
if (configurationFileLockPort.isPresent()) {
try {
configurationFileLockPort.get().acquireLock();
} catch (ConfigurationFileLockException e) {
LOG.warn("GUI-Mini-Verarbeitungslauf: Konfigurationsdatei gesperrt Lauf abgebrochen: {}",
e.getMessage());
fxDispatcher.accept(() -> showLockErrorAlert());
finishRun(GuiBatchRunLaunchOutcome.rejected(
"Konfigurationsdatei gesperrt Mini-Lauf wurde abgebrochen."));
return;
}
}
try {
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
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) {
LOG.info("GUI-Status-Reset: Worker-Thread gestartet für {} Dokument(e), "
+ "Konfiguration {}.", fingerprints.size(), configFilePath);
ResetDocumentStatusResult result;
try {
result = resetPort.reset(configFilePath, fingerprints);
if (result == null) {
result = new ResetDocumentStatusResult(fingerprints.size(),
Set.of(), allFailureMap(fingerprints,
"Reset-Port hat kein Ergebnis geliefert."));
}
} catch (RuntimeException e) {
LOG.error("GUI-Status-Reset: Unerwarteter Fehler im Worker-Thread: {}",
e.getMessage(), e);
String msg = "Unerwarteter technischer Fehler beim Status-Reset: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
result = new ResetDocumentStatusResult(fingerprints.size(),
Set.of(), allFailureMap(fingerprints, msg));
}
ResetDocumentStatusResult finalResult = result;
activeWorker.set(null);
fxDispatcher.accept(() -> listener.onResetCompleted(finalResult));
LOG.info("GUI-Status-Reset: Worker-Thread beendet.");
}
private void finishRun(GuiBatchRunLaunchOutcome outcome) {
RunSummary summary = observerSummary.get();
if (summary == null) {
summary = new RunSummary(0, 0, 0);
}
GuiBatchRunLaunchOutcome finalOutcome = outcome;
RunSummary finalSummary = summary;
activeWorker.set(null);
fxDispatcher.accept(() -> listener.onRunEnded(finalSummary, finalOutcome));
LOG.info("GUI-Verarbeitungslauf: Worker-Thread beendet.");
}
private static java.util.Map<DocumentFingerprint, String> allFailureMap(
Set<DocumentFingerprint> fingerprints, String message) {
java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
for (DocumentFingerprint fp : fingerprints) {
map.put(fp, message);
}
return map;
}
/**
* Captures the final summary supplied by the application layer. Written on the
* worker thread; read only after the run has ended.
*/
private final AtomicReference<RunSummary> observerSummary = new AtomicReference<>();
private BatchRunProgressObserver buildDispatchingObserver(Path configFilePath) {
return new BatchRunProgressObserver() {
@Override
public void onRunStarted(RunId runId, int totalCandidates) {
fxDispatcher.accept(() -> listener.onRunStarted(runId, totalCandidates));
}
@Override
public void onDocumentCompleted(DocumentCompletionEvent event) {
GuiBatchRunResultRow row = toRow(event, configFilePath);
fxDispatcher.accept(() -> listener.onDocumentCompleted(row));
}
@Override
public void onRunEnded(RunSummary summary) {
observerSummary.set(summary);
// Kein FX-Dispatch hier: der Worker-Thread ruft onRunEnded über finishRun()
// auf, nachdem der Launcher zurückgekehrt ist.
}
};
}
/**
* Wandelt ein {@link DocumentCompletionEvent} in eine {@link GuiBatchRunResultRow} um.
* <p>
* Für übersprungene Dokumente ({@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}
* und {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}) wird der historische
* Verarbeitungskontext über den {@link GuiHistoricalDocumentContextPort} nachgeladen.
* Für SKIPPED_ALREADY_PROCESSED wird der letzte Zieldateiname aus dem Kontext als
* {@code finalName} übernommen. Schlägt die Abfrage fehl, bleibt der Kontext leer.
* Die Methode läuft auf dem Worker-Thread.
*
* @param event das abgeschlossene Kandidatenereignis; darf nicht {@code null} sein
* @param configFilePath Pfad zur aktiven Konfigurationsdatei; darf nicht {@code null} sein
* @return eine neue {@link GuiBatchRunResultRow}; nie {@code null}
*/
private GuiBatchRunResultRow toRow(DocumentCompletionEvent event, Path configFilePath) {
Optional<String> finalName = event.finalFileName() == null
? Optional.empty() : Optional.of(event.finalFileName());
Optional<LocalDate> date = event.resolvedDate() == null
? Optional.empty() : Optional.of(event.resolvedDate());
Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
? Optional.empty() : Optional.of(event.aiReasoning());
Optional<String> failureMessage = event.failureMessage() == null || event.failureMessage().isBlank()
? Optional.empty() : Optional.of(event.failureMessage());
Duration duration = event.processingDuration();
// Historischen Kontext für übersprungene Dokumente nachladen
boolean isSkipped = event.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED
|| event.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE;
Optional<HistoricalDocumentContext> historicalContext = Optional.empty();
if (isSkipped) {
try {
historicalContext = historicalDocumentContextPort
.resolveHistoricalDocumentContext(configFilePath, event.fingerprint());
} catch (Exception e) {
LOG.warn("Historischer Kontext konnte nicht abgefragt werden für {}: {}",
event.originalFileName(), e.getMessage(), e);
}
// Zieldateiname für SKIPPED_ALREADY_PROCESSED aus Kontext übernehmen
if (finalName.isEmpty()) {
finalName = historicalContext
.flatMap(HistoricalDocumentContext::lastTargetFileName);
}
}
return new GuiBatchRunResultRow(
event.originalFileName(),
event.fingerprint(),
event.status(),
finalName,
Optional.empty(),
date,
reasoning,
failureMessage,
duration,
false,
historicalContext);
}
private static void showLockErrorAlert() {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Verarbeitungslauf nicht möglich");
alert.setHeaderText("Konfigurationsdatei gesperrt");
alert.setContentText(
"Der Verarbeitungslauf konnte nicht gestartet werden, da die "
+ "Konfigurationsdatei nicht gesperrt werden konnte.\n\n"
+ "Mögliche Ursache: Der automatische Scheduler ist aktiv oder "
+ "ein anderer Prozess hält die Datei belegt.\n\n"
+ "Bitte stoppen Sie den Scheduler und versuchen Sie es erneut.");
alert.showAndWait();
}
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
return (configPath, fingerprint) -> Optional.empty();
}
private static Function<Runnable, Thread> defaultThreadFactory() {
return task -> {
Thread thread = new Thread(task, WORKER_THREAD_NAME);
thread.setDaemon(true);
return thread;
};
}
private static Consumer<Runnable> defaultFxDispatcher() {
return Platform::runLater;
}
private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
return (configFilePath, fingerprintFilter, observer, cancellationToken) ->
GuiBatchRunLaunchOutcome.rejected(
"Kein Mini-Run-Launcher in diesem Kontext verfügbar.");
}
private static GuiResetDocumentStatusPort rejectingResetPort() {
return (configFilePath, fingerprints) ->
new ResetDocumentStatusResult(fingerprints.size(),
Set.of(), allFailureMapStatic(fingerprints,
"Kein Reset-Port in diesem Kontext verfügbar."));
}
private static java.util.Map<DocumentFingerprint, String> allFailureMapStatic(
Set<DocumentFingerprint> fingerprints, String message) {
java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
for (DocumentFingerprint fp : fingerprints) {
map.put(fp, message);
}
return map;
}
}
@@ -0,0 +1,77 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.Objects;
import java.util.Optional;
/**
* Immutable result of a single batch run launched from the GUI.
* <p>
* The outcome reports to the tab whether the run finished normally, could not even be
* started (hard failure), or ended because of an unexpected exception. The GUI uses this
* to transition between its "laufend" and "bereit"/"Fehler" states.
*
* <h2>Fields</h2>
* <ul>
* <li>{@link #successfullyStarted()} {@code true} when the launcher managed to enter
* the batch execution phase; {@code false} when the run was rejected before any
* candidate could be processed (e.g. configuration invalid, lock held, SQLite
* unavailable).</li>
* <li>{@link #batchCompletedNormally()} {@code true} when the run returned from the
* batch use case with a normal outcome (whether empty, partial, or full). Only
* meaningful when {@link #successfullyStarted()} is also {@code true}.</li>
* <li>{@link #failureMessage()} present when either the run could not start or an
* unexpected technical exception terminated it. Empty when the run completed
* normally.</li>
* </ul>
*/
public record GuiBatchRunLaunchOutcome(
boolean successfullyStarted,
boolean batchCompletedNormally,
Optional<String> failureMessage) {
/**
* Compact constructor normalising the failure message holder.
*/
public GuiBatchRunLaunchOutcome {
failureMessage = Objects.requireNonNullElse(failureMessage, Optional.empty());
}
/**
* Returns an outcome describing a run that finished normally.
*
* @return a started + completed outcome without failure message
*/
public static GuiBatchRunLaunchOutcome completed() {
return new GuiBatchRunLaunchOutcome(true, true, Optional.empty());
}
/**
* Returns an outcome describing a run that could not start because of a hard
* configuration, persistence, or lock failure.
*
* @param failureMessage the user-visible German failure description; must not be blank
* @return a rejected-startup outcome carrying the supplied message
*/
public static GuiBatchRunLaunchOutcome rejected(String failureMessage) {
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
if (failureMessage.isBlank()) {
throw new IllegalArgumentException("failureMessage must not be blank");
}
return new GuiBatchRunLaunchOutcome(false, false, Optional.of(failureMessage));
}
/**
* Returns an outcome describing a run that started but ended due to an unexpected
* technical exception.
*
* @param failureMessage the user-visible German failure description; must not be blank
* @return an aborted-after-start outcome carrying the supplied message
*/
public static GuiBatchRunLaunchOutcome failedAfterStart(String failureMessage) {
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
if (failureMessage.isBlank()) {
throw new IllegalArgumentException("failureMessage must not be blank");
}
return new GuiBatchRunLaunchOutcome(true, false, Optional.of(failureMessage));
}
}
@@ -0,0 +1,51 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
/**
* Inbound bridge implemented by Bootstrap to let the GUI execute a batch run against a
* stored configuration file.
* <p>
* The launcher performs the complete headless startup sequence (legacy migration, config
* loading, validation, SQLite schema initialisation, run-lock, use-case wiring, execution)
* for the supplied configuration path while forwarding progress callbacks and honouring
* the supplied cancellation token. It reuses the very same application ports and
* persistence pipeline as a Task-Scheduler-triggered headless run; only the presentation
* side (the GUI) differs.
*
* <h2>Threading</h2>
* <p>
* Implementations must be safe to call from a non-UI worker thread. They must not touch
* the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
* caller's concern. The call blocks until the run terminates (normally, after a
* cancellation, or after a hard failure).
*
* <h2>Exception contract</h2>
* <p>
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
* should be caught, logged, and returned as a
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
* well-defined terminal state.
*/
@FunctionalInterface
public interface GuiBatchRunLauncher {
/**
* Executes exactly one batch run against the supplied configuration file.
*
* @param configFilePath path of the {@code .properties} file to run against;
* must not be {@code null}; must exist and be readable
* @param observer observer receiving start/completion/end callbacks; must
* not be {@code null}
* @param cancellationToken cancellation token the run polls between candidates; must
* not be {@code null}
* @return a description of how the run terminated; never {@code null}
*/
GuiBatchRunLaunchOutcome launch(
Path configFilePath,
BatchRunProgressObserver observer,
BatchRunCancellationToken cancellationToken);
}
@@ -0,0 +1,285 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Immutable view model for a single row in the processing-run result list.
* <p>
* Each completed candidate becomes exactly one row. The row carries only the information
* that is shown in the list and the side panel; it is decoupled from the persistence
* model so later GUI layers can render it without reaching back into the application
* layer.
* <p>
* The {@code fingerprint} field is the content-based identity of the document and is
* used as a stable key for in-place row updates during a targeted mini-run.
* <p>
* When {@code resetPending} is {@code true} the row represents a document whose
* persistence status has been deleted but which has not yet been reprocessed. The status
* icon and label reflect this special state instead of the original processing outcome.
*
* @param originalFileName the source filename as reported by the use case; never
* {@code null} or blank
* @param fingerprint the content-based identity of the processed document; never
* {@code null}
* @param status the aggregated completion status; never {@code null}
* @param finalFileName the final target filename when the row represents a successful
* rename; empty otherwise
* @param correctedFileName Der manuell korrigierte Zieldateiname, falls der Benutzer den
* KI-Vorschlag in der GUI bearbeitet und gespeichert hat.
* Leer bei unverändertem KI-Vorschlag.
* @param resolvedDate the resolved document date when the row represents a successful
* rename; empty otherwise
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
* reasoning is available for this row
* @param aiFailureMessage eine lesbare Fehlerbeschreibung, wenn der KI-Aufruf oder die
* Verarbeitung fehlgeschlagen ist; leer bei Erfolg und
* übersprungenen Dokumenten
* @param processingDuration wall-clock duration spent on the candidate in this run;
* never {@code null} and never negative
* @param resetPending {@code true} when the document's persistence status has been
* reset and is awaiting the next processing run
* @param historicalContext historischer Verarbeitungskontext für übersprungene Dokumente;
* leer bei nicht-übersprungenen Zeilen
*/
public record GuiBatchRunResultRow(
String originalFileName,
DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
Optional<String> finalFileName,
Optional<String> correctedFileName,
Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration,
boolean resetPending,
Optional<HistoricalDocumentContext> historicalContext) {
/**
* Label shown in the status column when a document's persistence status has been
* reset and is waiting for the next processing run.
*/
static final String RESET_PENDING_LABEL = "Zurückgesetzt wartet auf nächsten Lauf";
/**
* Icon shown in the status column when a document's persistence status has been reset.
*/
static final String RESET_PENDING_ICON = ""; // CLOCKWISE GAPPED CIRCLE ARROW
/**
* Compact constructor normalising optional holders and validating mandatory fields.
*
* @throws NullPointerException if {@code originalFileName}, {@code fingerprint},
* {@code status} or {@code processingDuration} is
* {@code null}
* @throws IllegalArgumentException if {@code originalFileName} is blank or
* {@code processingDuration} is negative
*/
public GuiBatchRunResultRow {
Objects.requireNonNull(originalFileName, "originalFileName must not be null");
if (originalFileName.isBlank()) {
throw new IllegalArgumentException("originalFileName must not be blank");
}
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(status, "status must not be null");
finalFileName = Objects.requireNonNullElse(finalFileName, Optional.empty());
correctedFileName = Objects.requireNonNullElse(correctedFileName, Optional.empty());
resolvedDate = Objects.requireNonNullElse(resolvedDate, Optional.empty());
aiReasoning = Objects.requireNonNullElse(aiReasoning, Optional.empty());
aiFailureMessage = Objects.requireNonNullElse(aiFailureMessage, Optional.empty());
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
if (processingDuration.isNegative()) {
throw new IllegalArgumentException("processingDuration must not be negative");
}
historicalContext = Objects.requireNonNullElse(historicalContext, Optional.empty());
}
/**
* Bequem-Konstruktor für Zeilen, die weder einen manuell korrigierten Dateinamen
* tragen noch im reset-pending-Zustand stehen und keinen historischen Kontext haben.
*
* @param originalFileName the source filename; never {@code null} or blank
* @param fingerprint the content-based document identity; never {@code null}
* @param status the aggregated completion status; never {@code null}
* @param finalFileName the final target filename; may be {@code null} (treated as
* empty)
* @param resolvedDate the resolved document date; may be {@code null} (treated as
* empty)
* @param aiReasoning the AI reasoning text; may be {@code null} (treated as
* empty)
* @param aiFailureMessage eine lesbare Fehlerbeschreibung bei Fehler; may be
* {@code null} (treated as empty)
* @param processingDuration the wall-clock processing duration; never {@code null}
*/
public GuiBatchRunResultRow(
String originalFileName,
DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
Optional<String> finalFileName,
Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration) {
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, false,
Optional.empty());
}
/**
* Bequem-Konstruktor mit explizitem {@code resetPending}-Flag, aber ohne manuell
* korrigierten Dateinamen und ohne historischen Kontext.
*
* @param originalFileName the source filename; never {@code null} or blank
* @param fingerprint the content-based document identity; never {@code null}
* @param status the aggregated completion status; never {@code null}
* @param finalFileName the final target filename; may be {@code null} (treated as
* empty)
* @param resolvedDate the resolved document date; may be {@code null} (treated as
* empty)
* @param aiReasoning the AI reasoning text; may be {@code null} (treated as
* empty)
* @param aiFailureMessage eine lesbare Fehlerbeschreibung bei Fehler; may be
* {@code null} (treated as empty)
* @param processingDuration the wall-clock processing duration; never {@code null}
* @param resetPending {@code true} wenn der Stammsatz zurückgesetzt wurde
*/
public GuiBatchRunResultRow(
String originalFileName,
DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
Optional<String> finalFileName,
Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration,
boolean resetPending) {
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, resetPending,
Optional.empty());
}
/**
* Creates a reset-pending copy of the supplied row, preserving the original filename
* and fingerprint while marking the row as awaiting the next processing run.
* <p>
* The returned row has {@code resetPending == true}. Its {@code statusIcon()} and
* {@code statusLabel()} reflect the reset state.
*
* @param previousRow the row to copy; must not be {@code null}
* @return a new row with the same filename and fingerprint, {@code resetPending == true}
* @throws NullPointerException if {@code previousRow} is {@code null}
*/
public static GuiBatchRunResultRow resetMarker(GuiBatchRunResultRow previousRow) {
Objects.requireNonNull(previousRow, "previousRow must not be null");
return new GuiBatchRunResultRow(
previousRow.originalFileName(),
previousRow.fingerprint(),
previousRow.status(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
true,
Optional.empty());
}
/**
* Gibt das Status-Icon für diese Zeile als Unicode-Zeichen zurück, das in JavaFX
* unter Windows zuverlässig dargestellt wird (16px, bold).
* <p>
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
* eigentlichen Status das Reset-Icon zurückgegeben.
* <p>
* Die Icon-Werte stammen aus {@link ProcessingStatusPresentation}.
*
* @return das entsprechende Status-Zeichen
*/
public String statusIcon() {
if (resetPending) {
return RESET_PENDING_ICON;
}
return ProcessingStatusPresentation.iconFor(status);
}
/**
* Gibt die CSS-Farbe für das Status-Icon dieser Zeile zurück.
* <p>
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
* eigentlichen Status die Reset-Farbe zurückgegeben.
* <p>
* Farbe ist niemals das einzige Unterscheidungsmerkmal {@link #statusIcon()} und
* {@link #statusTooltip()} beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
* Die Farbwerte stammen aus {@link ProcessingStatusPresentation}.
*
* @return die entsprechende CSS-Hex-Farbe (z. B. {@code "#2e7d32"})
*/
public String statusColor() {
if (resetPending) {
return "#757575"; // Grau für Reset-pending
}
return ProcessingStatusPresentation.cssColorFor(status);
}
/**
* Gibt den deutschsprachigen Tooltip-Text für den Verarbeitungsstatus dieser Zeile zurück.
* <p>
* Wenn {@code resetPending} den Wert {@code true} hat, wird ein Tooltip für den
* Reset-Zustand zurückgegeben.
* <p>
* Der Tooltip-Text beschreibt den Status vollständig ohne Farbe. Die Texte stammen
* aus {@link ProcessingStatusPresentation}.
*
* @return der Tooltip-Text; nie leer
*/
public String statusTooltip() {
if (resetPending) {
return RESET_PENDING_LABEL;
}
return ProcessingStatusPresentation.tooltipFor(status);
}
/**
* Returns the human-readable status label for this row.
* <p>
* When {@code resetPending} is {@code true} the reset-pending label is returned
* regardless of the underlying status.
*
* @return a non-null German status label
*/
public String statusLabel() {
if (resetPending) {
return RESET_PENDING_LABEL;
}
return switch (status) {
case SUCCESS -> "Erfolgreich";
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
case FAILED_PERMANENT -> "Fehlgeschlagen (dauerhaft)";
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen (bereits verarbeitet)";
case SKIPPED_FINAL_FAILURE -> "Übersprungen (endgültig fehlgeschlagen)";
};
}
/**
* Liefert den aktuell wirksamen Zieldateinamen: falls der Benutzer den KI-Vorschlag
* manuell korrigiert und gespeichert hat, wird der korrigierte Name geliefert,
* ansonsten der ursprüngliche KI-Vorschlag {@link #finalFileName()}.
* <p>
* Die Tabellenspalte Neuer Dateiname" bindet an diesen Wert.
*
* @return den aktuell anzuzeigenden Zieldateinamen; leer wenn kein Name vorliegt
*/
public Optional<String> effectiveFileName() {
if (correctedFileName.isPresent()) {
return correctedFileName;
}
return finalFileName;
}
}
@@ -0,0 +1,42 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-interner Port zum Abfragen des historischen Verarbeitungskontexts einer Quelldatei.
* <p>
* Wird im Verarbeitungslauf-Tab genutzt, um für übersprungene Dokumente
* ({@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}
* und
* {@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED_FINAL_FAILURE})
* den historischen Kontext nachzuschlagen. Der Kontext wird im Detailbereich des
* Verarbeitungslauf-Tabs angezeigt.
* <p>
* Die Bootstrap-Schicht liefert die konkrete Implementierung. Sie lädt die
* Konfiguration aus {@code configFilePath}, baut den zugehörigen Use-Case auf und
* gibt das Ergebnis zurück. Technische Fehler beim Laden oder Abfragen werden intern
* abgefangen und als leeres {@link Optional} zurückgegeben.
* <p>
* Die Implementierung läuft auf dem Worker-Thread des {@link GuiBatchRunCoordinator}
* und darf blockieren.
*/
@FunctionalInterface
public interface GuiHistoricalDocumentContextPort {
/**
* Gibt den historischen Verarbeitungskontext für das durch {@code fingerprint}
* identifizierte Dokument zurück, oder ein leeres {@link Optional}, wenn kein
* Kontext verfügbar ist.
*
* @param configFilePath Pfad zur aktiven {@code .properties}-Konfigurationsdatei;
* darf nicht {@code null} sein
* @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
* @return historischer Kontext des Dokuments, oder leer wenn nicht verfügbar
*/
Optional<HistoricalDocumentContext> resolveHistoricalDocumentContext(
Path configFilePath, DocumentFingerprint fingerprint);
}
@@ -0,0 +1,38 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-interner Port zum Abfragen des historischen KI-Dateinamens einer Quelldatei.
* <p>
* Wird im Verarbeitungslauf-Tab genutzt, um für übersprungene Dokumente
* ({@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED})
* den aus einem früheren Lauf bekannten Zieldateinamen nachzuschlagen und in der Spalte
* Neuer Dateiname" der Ergebnistabelle anzuzeigen.
* <p>
* Die Bootstrap-Schicht liefert die konkrete Implementierung. Sie lädt die Konfiguration
* aus {@code configFilePath}, baut den zugehörigen Use-Case auf und gibt das Ergebnis
* zurück. Technische Fehler beim Laden oder Abfragen dürfen nicht als Exception propagiert
* werden; sie werden intern behandelt und als leeres {@link Optional} zurückgegeben.
* <p>
* Die Implementierung läuft auf dem Worker-Thread des {@link GuiBatchRunCoordinator}
* und darf blockieren.
*/
@FunctionalInterface
public interface GuiHistoricalFileNamePort {
/**
* Gibt den letzten erfolgreich geschriebenen Zieldateinamen für das durch
* {@code fingerprint} identifizierte Dokument zurück, oder ein leeres
* {@link Optional}, wenn kein solcher Name verfügbar ist.
*
* @param configFilePath Pfad zur aktiven {@code .properties}-Konfigurationsdatei;
* darf nicht {@code null} sein
* @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
* @return der historische Zieldateiname, oder leer wenn nicht vorhanden
*/
Optional<String> resolveHistoricalFileName(Path configFilePath, DocumentFingerprint fingerprint);
}
@@ -0,0 +1,48 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
/**
* Inbound-Brücke für die manuelle Kopie der Quelldatei eines bislang nicht erfolgreich
* verarbeiteten Dokuments aus der GUI.
* <p>
* Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen, wenn der
* Benutzer für ein nicht erfolgreich verarbeitetes Dokument (Status {@code FAILED_*} oder
* {@code SKIPPED_FINAL_FAILURE}) einen manuellen Zieldateinamen bestätigt. Der Port
* kapselt das vollständige Wiring (Konfigurationsauflösung, Use-Case-Konstruktion und
* Ausführung), sodass der GUI-Adapter keine Kenntnis von infrastrukturellen
* Implementierungsdetails benötigt.
*
* <h2>Threadingmodell</h2>
* <p>
* Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung ist
* synchron und blockierend: Sie kehrt erst zurück, wenn die Kopie abgeschlossen oder
* fehlgeschlagen ist. Aufrufer aus dem GUI-Layer müssen den Aufruf daher auf einem
* Hintergrund-Worker-Thread ausführen und das Ergebnis anschließend per
* {@code Platform.runLater} auf den JavaFX-Application-Thread zurückführen.
*
* <h2>Exception-Vertrag</h2>
* <p>
* Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete
* Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileCopyResult}
* zurückgegeben werden.
*/
@FunctionalInterface
public interface GuiManualFileCopyPort {
/**
* Kopiert die Quelldatei eines Dokuments mit benutzerdefiniertem Zieldateinamen ins
* Zielverzeichnis und aktualisiert den Dokument-Stammsatz auf {@code SUCCESS}.
*
* @param configFilePath Pfad zur {@code .properties}-Datei, die SQLite-Datenbank,
* Quell- und Zielordner beschreibt; darf nicht {@code null} sein;
* muss existieren und lesbar sein
* @param request die Kopieranfrage mit Fingerprint und gewünschtem
* Basisdateinamen; darf nicht {@code null} sein
* @return das Ergebnis der Kopieroperation; nie {@code null}
*/
ManualFileCopyResult copy(Path configFilePath, ManualFileCopyRequest request);
}
@@ -0,0 +1,46 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
/**
* Inbound-Brücke für die manuelle Dateiumbenennung aus der GUI.
* <p>
* Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen,
* wenn der Benutzer einen geänderten Dateinamen bestätigt. Der Port kapselt
* das vollständige Wiring (Konfigurationsauflösung, Use-Case-Konstruktion und
* Ausführung), sodass der GUI-Adapter keine Kenntnis von infrastrukturellen
* Implementierungsdetails benötigt.
*
* <h2>Threadingmodell</h2>
* <p>
* Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung
* ist synchron und blockierend: Sie kehrt erst zurück, wenn die Umbenennung
* abgeschlossen oder fehlgeschlagen ist. Aufrufer aus dem GUI-Layer müssen den
* Aufruf daher auf einem Hintergrund-Worker-Thread ausführen und das Ergebnis
* anschließend per {@code Platform.runLater} auf den JavaFX-Application-Thread
* zurückführen.
*
* <h2>Exception-Vertrag</h2>
* <p>
* Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete
* Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileRenameResult}
* zurückgegeben werden.
*/
@FunctionalInterface
public interface GuiManualFileRenamePort {
/**
* Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
*
* @param configFilePath Pfad zur {@code .properties}-Datei, die die SQLite-Datenbank
* und den Zielordner beschreibt; darf nicht {@code null} sein;
* muss existieren und lesbar sein
* @param request die Umbenennungsanfrage mit Fingerprint und gewünschtem
* Basisdateinamen; darf nicht {@code null} sein
* @return das Ergebnis der Umbenennung; nie {@code null}
*/
ManualFileRenameResult rename(Path configFilePath, ManualFileRenameRequest request);
}
@@ -0,0 +1,55 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.util.Set;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Inbound bridge implemented by Bootstrap to let the GUI execute a targeted mini batch
* run restricted to a specific set of document fingerprints.
* <p>
* A mini-run applies the full processing pipeline legacy migration, configuration
* loading, validation, SQLite schema initialisation, run-lock, use-case wiring, and
* execution but limits processing to the supplied fingerprint set. Documents not in
* the set are silently skipped without any persistence side-effects.
*
* <h2>Threading</h2>
* <p>
* Implementations must be safe to call from a non-UI worker thread. They must not touch
* the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
* caller's concern. The call blocks until the run terminates (normally, after a
* cancellation, or after a hard failure).
*
* <h2>Exception contract</h2>
* <p>
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
* should be caught, logged, and returned as a
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
* well-defined terminal state.
*/
@FunctionalInterface
public interface GuiMiniRunLauncher {
/**
* Executes a targeted batch run restricted to the supplied fingerprint set.
*
* @param configFilePath path of the {@code .properties} file to run against;
* must not be {@code null}; must exist and be readable
* @param fingerprintFilter the set of document fingerprints to process; must not be
* {@code null}; an empty set results in a completed run
* that processes nothing
* @param observer observer receiving start/completion/end callbacks; must
* not be {@code null}
* @param cancellationToken cancellation token the run polls between candidates; must
* not be {@code null}
* @return a description of how the run terminated; never {@code null}
*/
GuiBatchRunLaunchOutcome launch(
Path configFilePath,
Set<DocumentFingerprint> fingerprintFilter,
BatchRunProgressObserver observer,
BatchRunCancellationToken cancellationToken);
}
@@ -0,0 +1,45 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.util.Set;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Inbound bridge implemented by Bootstrap to let the GUI reset the processing status
* of one or more documents without triggering an immediate reprocessing run.
* <p>
* A reset deletes all persistence data (attempt history and document master record)
* for the specified fingerprints, making them eligible for reprocessing in the next
* regular or targeted batch run as if they had never been processed.
* <p>
* The operation follows best-effort semantics: each fingerprint is attempted
* independently. Technical failures for individual fingerprints are recorded in the
* result and do not abort the remaining resets.
*
* <h2>Threading</h2>
* <p>
* Implementations must be safe to call from a non-UI worker thread. The call blocks
* until all reset operations have completed or failed.
*
* <h2>Exception contract</h2>
* <p>
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
* should be caught and represented as failures in the result map.
*/
@FunctionalInterface
public interface GuiResetDocumentStatusPort {
/**
* Resets the processing status for the supplied set of document fingerprints.
*
* @param configFilePath path of the {@code .properties} file that identifies the
* SQLite database to operate on; must not be {@code null};
* must exist and be readable
* @param fingerprints the set of document fingerprints to reset; must not be
* {@code null}; may be empty
* @return a {@link ResetDocumentStatusResult} describing the full outcome; never null
*/
ResetDocumentStatusResult reset(Path configFilePath, Set<DocumentFingerprint> fingerprints);
}
@@ -0,0 +1,840 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import javafx.scene.Cursor;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
/**
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
*
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
* in einer {@link ImageView} an. Im Fit-to-View-Modus (Standardzustand) sind
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} an die Größe des
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
* Seitenverhältnis. Die Seite füllt den verfügbaren Bereich ohne Scrollbalken.
*
* <p><strong>Mausrad-Zoom:</strong> Strg + Mausrad ändert den Zoomfaktor in Stufen von
* 10 % pro Raste (Bereich {@value #ZOOM_MIN}{@value #ZOOM_MAX}, d. h. 10 %500 %).
* Beim ersten manuellen Zoom wird der Fit-to-View-Modus verlassen und ein
* {@link ScrollPane} übernimmt das Scrollen. Das Laden einer neuen Datei setzt den
* Zoom automatisch auf Fit-to-View zurück.
*
* <p><strong>Grab &amp; Pan:</strong> Im manuellen Zoom-Modus kann die Vorschau durch
* Klicken und Ziehen (linke Maustaste) verschoben werden. Der Mauszeiger wechselt im
* Zoom-Modus auf {@link Cursor#OPEN_HAND} und während der Geste auf
* {@link Cursor#CLOSED_HAND}.
*
* <p>Das Laden der PDF-Datei und das Rendering einzelner Seiten erfolgt auf einem
* dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX
* Application Thread. Bereits gerenderte Seiten werden in einem In-Memory-Cache
* ({@code Map<Integer, Image>}) gehalten, sodass wiederholte Navigation kein
* erneutes Rendering erfordert. Der Cache wird beim Wechsel der Quelldatei geleert.
*
* <p>Es gilt das Prinzip Latest Preview Request Wins": Veraltete Lade- und
* Rendering-Ergebnisse werden anhand einer Sequenznummer erkannt und verworfen,
* sobald eine neue Anforderung eingeht.
*
* <h2>Fehlerfälle</h2>
* <ul>
* <li>Quelldatei nicht vorhanden Meldungstext im Vorschaubereich</li>
* <li>PDF nicht lesbar Meldungstext im Vorschaubereich</li>
* <li>PDF passwortgeschützt Meldungstext im Vorschaubereich</li>
* <li>Keine Selektion neutraler Platzhaltertext</li>
* </ul>
*
* <h2>Threading</h2>
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
* werden. Das PDF-Öffnen, die Speicherhaltung des {@link PDDocument} und das
* Rendering einzelner Seiten laufen ausschließlich auf dem Worker-Thread.
*/
public final class PdfPreviewPane {
private static final Logger LOG = LogManager.getLogger(PdfPreviewPane.class);
static final String PLACEHOLDER_TEXT = "Keine Datei ausgewählt";
static final String FILE_NOT_FOUND_TEXT = "Quelldatei nicht gefunden";
static final String PDF_UNREADABLE_TEXT = "PDF konnte nicht geöffnet werden";
static final String PDF_PASSWORD_PROTECTED_TEXT =
"PDF ist passwortgeschützt und kann nicht angezeigt werden";
/** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
private static final float RENDER_DPI = 120f;
/** Minimaler Zoomfaktor (10 %). */
static final double ZOOM_MIN = 0.10;
/** Maximaler Zoomfaktor (500 %). */
static final double ZOOM_MAX = 5.00;
/** Zoom-Schrittgröße pro Mausrad-Raste (10 %). */
private static final double ZOOM_STEP = 0.10;
/** Typischer vertikaler Scroll-Delta pro Mausrad-Raste. */
private static final double ZOOM_NOTCH_THRESHOLD = 40.0;
private final VBox root = new VBox(4);
private final StackPane viewStack = new StackPane();
private final ImageView imageView = new ImageView();
private final Label overlayLabel = new Label(PLACEHOLDER_TEXT);
private final ProgressIndicator progressIndicator = new ProgressIndicator();
private final Label pageLabel = new Label();
private final Button prevButton = new Button("◀ Vorherige");
private final Button nextButton = new Button("Nächste ▶");
private final Label sectionTitle = new Label("PDF-Vorschau");
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
* (Laden oder Seitenwechsel) erhöht diesen Zähler. Lade-/Rendering-Ergebnisse
* mit veralteter Sequenznummer werden verworfen.
*/
private final AtomicLong currentRequestSequence = new AtomicLong(0);
/**
* Cache bereits gerenderter Seiten für die aktuell geladene Quelldatei.
* Schlüssel ist die 1-basierte Seitennummer. Wird beim Wechsel der Quelldatei geleert.
*/
private final Map<Integer, Image> pageCache = new ConcurrentHashMap<>();
/** Hintergrund-Thread-Pool für Lade- und Rendering-Aufgaben. */
private final ExecutorService executor =
Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "pdf-preview-worker");
t.setDaemon(true);
return t;
});
/**
* Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread.
* Leerer Referenzwert wenn kein Dokument geöffnet ist.
*/
private final AtomicReference<PDDocument> currentDocument = new AtomicReference<>();
/**
* Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread.
* Leerer Referenzwert wenn kein Dokument geöffnet ist.
*/
private final AtomicReference<PDFRenderer> currentRenderer = new AtomicReference<>();
/** Aktuell geladene Quelldatei; leerer Referenzwert wenn keine Selektion vorliegt. */
private final AtomicReference<Path> currentSourceFile = new AtomicReference<>();
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
private volatile int currentPage = 0;
/** Anzahl der Seiten der aktuell geladenen PDF; -1 wenn nicht ermittelt. */
private volatile int totalPages = -1;
/** Gibt an ob die Navigation bedienbar ist. */
private boolean enabled = true;
/**
* Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
*/
public PdfPreviewPane() {
sectionTitle.setStyle("-fx-font-weight: bold;");
imageView.setId("pdf-preview-image-view");
imageView.setPreserveRatio(true);
imageView.setSmooth(true);
// Fit-to-view: ImageView füllt den verfügbaren Bereich unter Wahrung des Seitenverhältnisses
imageView.fitWidthProperty().bind(viewStack.widthProperty());
imageView.fitHeightProperty().bind(viewStack.heightProperty());
overlayLabel.setId("pdf-preview-overlay-label");
overlayLabel.setStyle("-fx-text-fill: #555555;");
overlayLabel.setWrapText(true);
overlayLabel.setVisible(true);
overlayLabel.setManaged(true);
progressIndicator.setId("pdf-preview-progress");
progressIndicator.setVisible(false);
progressIndicator.setManaged(false);
progressIndicator.setMaxWidth(60);
progressIndicator.setMaxHeight(60);
// Stack: ImageView hinter dem Overlay; Overlay überlagert das Bild bei Fehlern/Laden
viewStack.getChildren().addAll(imageView, overlayLabel, progressIndicator);
StackPane.setAlignment(imageView, Pos.CENTER);
StackPane.setAlignment(overlayLabel, Pos.CENTER);
StackPane.setAlignment(progressIndicator, Pos.CENTER);
scrollPane.setId("pdf-preview-scroll-pane");
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
// 32c: Verhindert, dass ScrollPane und StackPane beim manuellen Zoom mitwachsen
scrollPane.setPrefSize(0, 0);
viewStack.setMinSize(0, 0);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
// Strg + Mausrad Zoom; ohne Strg normales Scrollen
scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
if (event.isControlDown()) {
accumulateAndApplyZoomDelta(event.getDeltaY());
event.consume();
}
});
// Grab & Pan im manuellen Zoom-Modus mit Maus verschiebbar
viewStack.addEventHandler(MouseEvent.MOUSE_PRESSED, this::onPanMousePressed);
viewStack.addEventHandler(MouseEvent.MOUSE_DRAGGED, this::onPanMouseDragged);
viewStack.addEventHandler(MouseEvent.MOUSE_RELEASED, this::onPanMouseReleased);
// viewStack ist immer mindestens so groß wie der Viewport. Ist der Inhalt
// (ImageView) kleiner als der Viewport, sorgt diese Mindestgröße zusammen
// mit StackPane.Pos.CENTER dafür, dass die ImageView automatisch zentriert
// wird ohne manuelle setHvalue/setVvalue-Eingriffe. Ist der Inhalt größer,
// bleibt die Mindestgröße wirkungslos und der ScrollPane scrollt normal.
scrollPane.viewportBoundsProperty().addListener((obs, old, bounds) -> {
viewStack.setMinWidth(bounds.getWidth());
viewStack.setMinHeight(bounds.getHeight());
});
prevButton.setId("pdf-preview-prev-button");
prevButton.setOnAction(e -> navigateToPreviousPage());
Tooltip prevTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_VORHERIGE_SEITE);
prevTooltip.setShowDelay(Duration.millis(300));
prevButton.setTooltip(prevTooltip);
nextButton.setId("pdf-preview-next-button");
nextButton.setOnAction(e -> navigateToNextPage());
Tooltip nextTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_NAECHSTE_SEITE);
nextTooltip.setShowDelay(Duration.millis(300));
nextButton.setTooltip(nextTooltip);
pageLabel.setId("pdf-preview-page-label");
pageLabel.setStyle("-fx-text-fill: #555555;");
HBox navBar = new HBox(8, prevButton, pageLabel, nextButton);
navBar.setAlignment(Pos.CENTER);
navBar.setPadding(new Insets(4, 0, 4, 0));
root.getChildren().addAll(sectionTitle, scrollPane, navBar);
root.setPadding(new Insets(4, 0, 0, 0));
showPlaceholder();
updateNavigationButtons();
}
/**
* Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
*
* @return das Root-Control; nie null
*/
public Region getNode() {
return root;
}
/**
* Lädt die angegebene Quelldatei asynchron und zeigt Seite 1 an.
* Startet eine neue Vorschau-Anforderung und verwirft etwaige laufende Anforderungen.
* Der Seiten-Cache wird geleert und ein etwaiges bereits geöffnetes PDF-Dokument
* wird geschlossen.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param sourceFile Pfad zur Quelldatei; null führt zu {@link #clear()}
*/
public void loadSource(Path sourceFile) {
if (sourceFile == null) {
clear();
return;
}
currentSourceFile.set(sourceFile);
currentPage = 0;
totalPages = -1;
pageCache.clear();
resetToFitView();
requestLoad(sourceFile);
}
/**
* Leert die Komponente und zeigt den neutralen Platzhaltertext.
* Das aktuell geöffnete PDF-Dokument wird asynchron auf dem Worker-Thread geschlossen.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clear() {
currentSourceFile.set(null);
currentPage = 0;
totalPages = -1;
pageCache.clear();
// Neue Sequenznummer: laufende Requests werden verworfen
currentRequestSequence.incrementAndGet();
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
executor.submit(this::closeCurrentDocumentOnWorker);
resetToFitView();
imageView.setImage(null);
showPlaceholder();
updateNavigationButtons();
}
/**
* Aktiviert oder deaktiviert die Navigations-Buttons.
* Während eines laufenden Batch-Laufs soll die Navigation deaktiviert sein.
* Die Vorschau-Anzeige bleibt sichtbar.
*
* @param enabled {@code true} wenn Navigation erlaubt ist
*/
public void setEnabled(boolean enabled) {
this.enabled = enabled;
updateNavigationButtons();
}
/**
* Beendet den internen Executor sauber und schließt das eventuell noch offene
* PDF-Dokument. Muss beim Schließen der Anwendung aufgerufen werden.
*/
public void shutdown() {
try {
executor.submit(this::closeCurrentDocumentOnWorker);
} catch (RuntimeException ignored) {
// Executor wurde bereits beendet keine Aktion erforderlich
}
executor.shutdown();
}
// --- Test-Accessoren ------------------------------------------------------
/** Visible for tests. */
Label overlayLabel() {
return overlayLabel;
}
/** Visible for tests. */
Button prevButton() {
return prevButton;
}
/** Visible for tests. */
Button nextButton() {
return nextButton;
}
/** Visible for tests. */
Label pageLabel() {
return pageLabel;
}
/** Visible for tests. */
ProgressIndicator progressIndicator() {
return progressIndicator;
}
/** Visible for tests. */
ScrollPane scrollPane() {
return scrollPane;
}
/** Visible for tests. */
double zoomLevel() {
return zoomLevel;
}
// --- Navigation -----------------------------------------------------------
private void navigateToPreviousPage() {
if (!enabled || currentPage <= 1) {
return;
}
goToPage(currentPage - 1);
}
private void navigateToNextPage() {
if (!enabled || totalPages <= 0 || currentPage >= totalPages) {
return;
}
goToPage(currentPage + 1);
}
/**
* Wechselt zur angegebenen Seite. Bereits gerenderte Seiten werden direkt aus dem
* Cache angezeigt; ansonsten wird ein Rendering-Auftrag auf den Worker-Thread gelegt.
*
* @param targetPage Ziel-Seite (1-basiert, muss im gültigen Bereich liegen)
*/
private void goToPage(int targetPage) {
currentPage = targetPage;
updatePageLabel();
updateNavigationButtons();
Image cached = pageCache.get(targetPage);
if (cached != null) {
imageView.setImage(cached);
showContent();
return;
}
long seq = currentRequestSequence.incrementAndGet();
showLoading();
executor.submit(() -> renderPageOnWorker(targetPage, seq));
}
// --- Asynchrones Laden und Rendering --------------------------------------
/**
* Startet eine asynchrone Lade-Anforderung für die angegebene Datei.
* Erhöht die Sequenznummer, damit veraltete Ergebnisse erkannt und verworfen werden.
*
* @param file die zu ladende Quelldatei
*/
private void requestLoad(Path file) {
long seq = currentRequestSequence.incrementAndGet();
LOG.debug("PDF-Vorschau: Lade {} (Anforderung #{})", file, seq);
showLoading();
updateNavigationButtons();
executor.submit(() -> loadAndRenderFirstPageOnWorker(file, seq));
}
/**
* Öffnet die PDF-Datei, ermittelt die Seitenzahl und rendert die erste Seite.
* Läuft ausschließlich auf dem Worker-Thread.
*
* @param file die zu ladende Datei
* @param seq die Sequenznummer dieser Anforderung
*/
private void loadAndRenderFirstPageOnWorker(Path file, long seq) {
File ioFile = file.toFile();
if (!ioFile.exists()) {
LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen Datei nicht gefunden: {}", file);
publishError(seq, FILE_NOT_FOUND_TEXT);
return;
}
// Vorheriges Dokument schließen bevor ein neues geöffnet wird
closeCurrentDocumentOnWorker();
try {
PDDocument doc = Loader.loadPDF(ioFile);
currentDocument.set(doc);
PDFRenderer renderer = new PDFRenderer(doc);
currentRenderer.set(renderer);
int pages = Math.max(1, doc.getNumberOfPages());
BufferedImage buffered =
renderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
final int totalPagesFinal = pages;
Platform.runLater(() -> {
if (currentRequestSequence.get() != seq) {
return; // Veraltet verwerfen
}
totalPages = totalPagesFinal;
currentPage = 1;
pageCache.put(1, fxImage);
imageView.setImage(fxImage);
showContent();
updateNavigationButtons();
updatePageLabel();
LOG.debug("PDF-Vorschau: Rendering abgeschlossen {} Seite(n)", totalPagesFinal);
});
} catch (InvalidPasswordException ipe) {
LOG.warn("PDF-Vorschau: PDF ist passwortgeschützt: {}", file, ipe);
closeCurrentDocumentOnWorker();
publishError(seq, PDF_PASSWORD_PROTECTED_TEXT);
} catch (Exception e) {
LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen: {}", file, e);
closeCurrentDocumentOnWorker();
publishError(seq, PDF_UNREADABLE_TEXT);
}
}
/**
* Rendert eine einzelne Seite des aktuell geöffneten Dokuments.
* Läuft ausschließlich auf dem Worker-Thread.
*
* @param page 1-basierte Seitennummer
* @param seq die Sequenznummer dieser Anforderung
*/
private void renderPageOnWorker(int page, long seq) {
PDFRenderer renderer = currentRenderer.get();
if (renderer == null) {
// Dokument wurde zwischenzeitlich geschlossen nichts zu tun
return;
}
try {
BufferedImage buffered = renderer.renderImageWithDPI(page - 1, RENDER_DPI, ImageType.RGB);
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
Platform.runLater(() -> {
if (currentRequestSequence.get() != seq) {
return; // Veraltet verwerfen
}
pageCache.put(page, fxImage);
if (currentPage == page) {
imageView.setImage(fxImage);
showContent();
}
});
} catch (Exception e) {
LOG.warn("PDF-Vorschau: Rendering von Seite {} fehlgeschlagen", page, e);
publishError(seq, PDF_UNREADABLE_TEXT);
}
}
/**
* Schließt das aktuell geöffnete PDF-Dokument, falls vorhanden. Läuft ausschließlich
* auf dem Worker-Thread und ist idempotent.
*/
private void closeCurrentDocumentOnWorker() {
PDDocument doc = currentDocument.getAndSet(null);
currentRenderer.set(null);
if (doc != null) {
try {
doc.close();
} catch (Exception e) {
LOG.debug("PDF-Vorschau: Schließen des Dokuments schlug fehl", e);
}
}
}
/**
* Übergibt eine Fehlermeldung auf den FX-Thread. Veraltete Meldungen werden verworfen.
*
* @param seq Sequenznummer der Anforderung, zu der die Meldung gehört
* @param message anzuzeigende Fehlermeldung
*/
private void publishError(long seq, String message) {
Platform.runLater(() -> {
if (currentRequestSequence.get() != seq) {
return;
}
showError(message);
updateNavigationButtons();
});
}
// --- Grab & Pan -----------------------------------------------------------
/**
* Startet die Pan-Geste. Speichert die Startposition und den aktuellen Scroll-Zustand.
* Nur aktiv wenn der manuelle Zoom-Modus eingeschaltet ist.
*
* @param event das Maus-Pressed-Ereignis
*/
private void onPanMousePressed(MouseEvent event) {
if (scrollPane.isFitToWidth()) {
return; // Im Fit-Modus kein Pan nötig
}
panStartX = event.getScreenX();
panStartY = event.getScreenY();
panStartHvalue = scrollPane.getHvalue();
panStartVvalue = scrollPane.getVvalue();
viewStack.setCursor(Cursor.CLOSED_HAND);
event.consume();
}
/**
* Verschiebt den Viewport relativ zur Startposition der Pan-Geste.
* Die Scrolldelta wird auf die scrollbaren Bereiche des Inhalts normiert.
*
* @param event das Maus-Dragged-Ereignis
*/
private void onPanMouseDragged(MouseEvent event) {
if (panStartX < 0 || scrollPane.isFitToWidth()) {
return;
}
double dx = event.getScreenX() - panStartX;
double dy = event.getScreenY() - panStartY;
Bounds viewport = scrollPane.getViewportBounds();
double contentWidth = viewStack.getWidth();
double contentHeight = viewStack.getHeight();
double viewportWidth = viewport != null ? viewport.getWidth() : 0;
double viewportHeight = viewport != null ? viewport.getHeight() : 0;
double scrollableWidth = contentWidth - viewportWidth;
double scrollableHeight = contentHeight - viewportHeight;
if (scrollableWidth > 0) {
double newHval = panStartHvalue - dx / scrollableWidth;
scrollPane.setHvalue(Math.max(0, Math.min(1, newHval)));
}
if (scrollableHeight > 0) {
double newVval = panStartVvalue - dy / scrollableHeight;
scrollPane.setVvalue(Math.max(0, Math.min(1, newVval)));
}
event.consume();
}
/**
* Beendet die Pan-Geste und stellt den OPEN_HAND-Mauszeiger wieder her.
*
* @param event das Maus-Released-Ereignis
*/
private void onPanMouseReleased(MouseEvent event) {
panStartX = -1;
panStartY = -1;
if (!scrollPane.isFitToWidth()) {
viewStack.setCursor(Cursor.OPEN_HAND);
}
event.consume();
}
// --- Zoom -----------------------------------------------------------------
/**
* Akkumuliert den Scroll-Delta und wendet den Zoom schrittweise an.
* Pro Raste (ca. {@value #ZOOM_NOTCH_THRESHOLD} Einheiten) ändert sich der Zoom
* um {@value #ZOOM_STEP}. Pro ScrollEvent wird maximal eine Zoom-Stufe angewendet.
*
* <p>Der Rohwert von {@code deltaY} wird vor der Akkumulation auf einen
* Notch-Wert ({@value #ZOOM_NOTCH_THRESHOLD}) begrenzt. Plattformspezifische
* Scroll-Multiplikatoren (z. B. Windows-Mausgeschwindigkeit, hohe DPI-Mäuse)
* können sonst Werte wie 120 oder mehr pro Raste liefern, was einen
* Akkumulator-Überlauf in Folge-Events verursacht.
*
* @param deltaY vertikaler Scroll-Delta des {@link ScrollEvent}
*/
private void accumulateAndApplyZoomDelta(double deltaY) {
// Normierung: maximal einen Notch-Wert pro Event akkumulieren, um
// plattformspezifische deltaY-Überhöhungen (z. B. 120 statt 40) abzufangen
double capped = Math.signum(deltaY) * Math.min(Math.abs(deltaY), ZOOM_NOTCH_THRESHOLD);
zoomAccumulator += capped;
if (zoomAccumulator >= ZOOM_NOTCH_THRESHOLD) {
zoomAccumulator -= ZOOM_NOTCH_THRESHOLD;
applyZoom(Math.min(ZOOM_MAX, zoomLevel + ZOOM_STEP));
} else if (zoomAccumulator <= -ZOOM_NOTCH_THRESHOLD) {
zoomAccumulator += ZOOM_NOTCH_THRESHOLD;
applyZoom(Math.max(ZOOM_MIN, zoomLevel - ZOOM_STEP));
}
}
/**
* Setzt den Zoomfaktor und verlässt beim ersten Aufruf den Fit-to-View-Modus.
* <p>
* Beim ersten Aufruf (Wechsel aus dem Fit-Modus) wird {@code zoomLevel} auf
* den aktuellen visuellen Skalierungsfaktor kalibriert: aktuelle visuelle
* Breite der ImageView (mit {@code preserveRatio} bereits aspekt-korrekt
* verkleinert) geteilt durch die natürliche Bildbreite. Damit entspricht
* {@code zoomLevel = 1.0} der pixel-genauen Originalgröße, und der erste
* Zoom-Schritt addiert sich auf den realen Skalierungsfaktor. Ohne diese
* Kalibrierung springt die ImageView abrupt auf {@code Viewport-Breite × 1.10},
* weil im Fit-Modus die {@code fitHeight}-Bindung das Bild aspekt-erhaltend
* deutlich kleiner zwingt als {@code naturalViewportWidth × 1.0} ergibt.
* Da der Caller den Delta-Schritt auf dem alten {@code zoomLevel = 1.0}
* berechnet hat, wird er nach der Kalibrierung auf den neuen, kalibrierten
* {@code zoomLevel} re-appliziert.
* <p>
* Beim Wechsel aus dem Fit-to-View-Modus wird die Ansicht auf die Bildmitte
* zentriert (H/V = 0.5). Bei weiteren Zoom-Schritten bleibt die aktuelle
* Scrollposition erhalten. Ein {@code layout()}-Aufruf vor der
* Positionswiederherstellung stellt sicher, dass die neuen Inhaltsgrenzen
* bereits berechnet sind.
*
* @param newZoom gewünschter Zoomfaktor, wird auf [{@link #ZOOM_MIN}, {@link #ZOOM_MAX}] begrenzt
*/
private void applyZoom(double newZoom) {
double effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom));
boolean wasInFitMode = scrollPane.isFitToWidth();
if (wasInFitMode) {
Image image = imageView.getImage();
if (image == null || image.getWidth() <= 0) {
return; // Kein Bild Zoom-Kalibrierung nicht möglich
}
double naturalImageWidth = image.getWidth();
double currentVisualWidth = imageView.getBoundsInLocal().getWidth();
if (currentVisualWidth <= 0) {
Bounds viewport = scrollPane.getViewportBounds();
currentVisualWidth = viewport != null ? viewport.getWidth() : viewStack.getWidth();
if (currentVisualWidth <= 0) {
return; // Layout noch nicht abgeschlossen
}
}
// Vom Caller intendierten Delta-Schritt vor der Kalibrierung sichern
double requestedDelta = newZoom - zoomLevel;
// zoomLevel auf den aktuellen visuellen Skalierungsfaktor kalibrieren
naturalViewportWidth = naturalImageWidth;
zoomLevel = currentVisualWidth / naturalImageWidth;
// effective neu berechnen, weil zoomLevel sich geändert hat
effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoomLevel + requestedDelta));
scrollPane.setFitToWidth(false);
scrollPane.setFitToHeight(false);
imageView.fitWidthProperty().unbind();
imageView.fitHeightProperty().unbind();
// Mauszeiger signalisiert Pan-Modus
viewStack.setCursor(Cursor.OPEN_HAND);
}
if (effective == zoomLevel) {
return;
}
zoomLevel = effective;
imageView.setFitWidth(naturalViewportWidth * zoomLevel);
imageView.setFitHeight(0);
// Keine manuellen setHvalue/setVvalue-Eingriffe nötig: viewStack hat
// dank des viewportBoundsProperty-Listeners im Konstruktor mindestens
// Viewport-Größe, und Pos.CENTER sorgt für automatische Zentrierung,
// wenn der Inhalt kleiner als der Viewport ist.
}
/**
* Setzt Zoom, Akkumulator und Pan-Zustand zurück und reaktiviert den Fit-to-View-Modus.
* Wird beim Laden einer neuen Datei und beim Leeren der Komponente aufgerufen.
*
* <p>Reihenfolge der Aktionen ist kritisch:
* <ol>
* <li>{@code setFitToWidth(true)} und {@code setFitToHeight(true)} sofort,
* damit der nächste Layout-Pass den {@code viewStack} auf Viewport-Größe
* zurückrechnet.</li>
* <li>Property-Bindungen und H/V-Reset im {@code Platform.runLater}, damit
* sie auf die bereits zurückgerechneten {@code viewStack}-Dimensionen
* wirken und nicht auf die noch zoom-große Breite.</li>
* </ol>
* Ohne diese Reihenfolge würden die Bindungen die imageView kurz an die
* Zoom-Größe koppeln, und ein verbleibender H/V-Wert aus dem Pan-/Zoom-Modus
* (z. B. {@code hvalue=0.0} nach Pan zum linken Rand) würde die PDF wegen
* kleinster Rounding-/Border-Differenzen links/oben bündig statt zentriert
* anzeigen, obwohl der ScrollPane fit-aktiv ist.
*/
private void resetToFitView() {
zoomLevel = 1.0;
zoomAccumulator = 0.0;
naturalViewportWidth = 0.0;
// Pan-Zustand und Mauszeiger zurücksetzen
panStartX = -1;
panStartY = -1;
viewStack.setCursor(null);
if (!scrollPane.isFitToWidth()) {
// 1. ScrollPane in Fit-Modus schalten, damit der nächste Layout-Pass
// den viewStack auf Viewport-Größe zurückrechnet
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
// 2. Bindings erst nach abgeschlossenem Layout-Pass, damit sie auf
// die zurückgerechneten viewStack-Dimensionen wirken
Platform.runLater(() -> {
imageView.fitWidthProperty().bind(viewStack.widthProperty());
imageView.fitHeightProperty().bind(viewStack.heightProperty());
});
}
}
// --- UI-Zustandshelfer ---------------------------------------------------
private void showPlaceholder() {
overlayLabel.setText(PLACEHOLDER_TEXT);
overlayLabel.setVisible(true);
overlayLabel.setManaged(true);
imageView.setVisible(false);
imageView.setManaged(false);
progressIndicator.setVisible(false);
progressIndicator.setManaged(false);
pageLabel.setText("");
}
private void showLoading() {
progressIndicator.setVisible(true);
progressIndicator.setManaged(true);
overlayLabel.setVisible(false);
overlayLabel.setManaged(false);
imageView.setVisible(false);
imageView.setManaged(false);
}
private void showContent() {
progressIndicator.setVisible(false);
progressIndicator.setManaged(false);
overlayLabel.setVisible(false);
overlayLabel.setManaged(false);
imageView.setVisible(true);
imageView.setManaged(true);
}
private void showError(String message) {
overlayLabel.setText(message);
overlayLabel.setVisible(true);
overlayLabel.setManaged(true);
imageView.setVisible(false);
imageView.setManaged(false);
progressIndicator.setVisible(false);
progressIndicator.setManaged(false);
pageLabel.setText("");
}
private void updateNavigationButtons() {
boolean canNavigate = enabled && currentSourceFile.get() != null && totalPages > 0;
prevButton.setDisable(!canNavigate || currentPage <= 1);
nextButton.setDisable(!canNavigate || currentPage >= totalPages);
}
private void updatePageLabel() {
if (totalPages > 0 && currentPage > 0) {
pageLabel.setText("Seite " + currentPage + " / " + totalPages);
} else {
pageLabel.setText("");
}
}
}
@@ -0,0 +1,287 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
/**
* Zentrale Mapping-Klasse für die visuelle Darstellung von Verarbeitungsstatus in der GUI.
* <p>
* Diese Klasse ist die einzige autoritative Quelle für Status-Icons, CSS-Farben,
* Tooltip-Texte und Summary-Kategorielabels aller {@link DocumentCompletionStatus}-Werte.
* Alle Anzeigeorte im GUI-Adapter (Ergebnistabelle, Detailbereich, Summary-Banner)
* beziehen ihre Darstellungsinformationen ausschließlich über diese Klasse.
* <p>
* Farbe ist niemals das einzige Unterscheidungsmerkmal: Icon und Tooltip-Text beschreiben
* den Status vollständig auch ohne Farb­wahrnehmung.
* <p>
* Diese Klasse enthält keine JavaFX-Typen; sie ist rein datenhaltend und zustandslos.
* Alle Methoden sind statisch.
*/
public final class ProcessingStatusPresentation {
private static final String STATUS_NOT_NULL = "status darf nicht null sein";
// -------------------------------------------------------------------------
// Icons (Unicode-Zeichen, zuverlässig darstellbar unter Windows 10+)
// -------------------------------------------------------------------------
/** Icon für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String ICON_SUCCESS = ""; // CHECK MARK
/** Icon für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String ICON_FAILED_RETRYABLE = ""; // CLOCKWISE OPEN CIRCLE ARROW
/** Icon für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String ICON_FAILED_PERMANENT = "×"; // MULTIPLICATION SIGN
/** Icon für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String ICON_SKIPPED_ALREADY_PROCESSED = ""; // IDENTICAL TO
/** Icon für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String ICON_SKIPPED_FINAL_FAILURE = ""; // CIRCLED DIVISION SLASH
// -------------------------------------------------------------------------
// CSS-Farben (Hex-Strings für JavaFX setStyle)
// -------------------------------------------------------------------------
/** CSS-Farbe für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String COLOR_SUCCESS = "#2e7d32"; // Grün
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String COLOR_FAILED_RETRYABLE = "#d98200"; // Orange
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String COLOR_FAILED_PERMANENT = "#c62828"; // Rot
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String COLOR_SKIPPED_ALREADY_PROCESSED = "#757575"; // Grau
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String COLOR_SKIPPED_FINAL_FAILURE = "#424242"; // Dunkelgrau
// -------------------------------------------------------------------------
// Tooltip-Texte (deutsche Benutzertexte, gemäß Spezifikation)
// -------------------------------------------------------------------------
/** Tooltip für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String TOOLTIP_SUCCESS =
"Erfolgreich verarbeitet und umbenannt.";
/** Tooltip für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String TOOLTIP_FAILED_RETRYABLE =
"Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht.";
/** Tooltip für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String TOOLTIP_FAILED_PERMANENT =
"Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), Passwortschutz "
+ "oder beschädigte Datei. Kein weiterer automatischer Versuch.";
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String TOOLTIP_SKIPPED_ALREADY_PROCESSED =
"Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String TOOLTIP_SKIPPED_FINAL_FAILURE =
"Endgültig übersprungen nach wiederholten Fehlern.";
// -------------------------------------------------------------------------
// Detailtext für FAILED_PERMANENT (Erklärung im Detailbereich)
// -------------------------------------------------------------------------
/**
* Erweiterter Erklärungstext, der im Detailbereich bei dauerhaft fehlgeschlagenen
* Dokumenten angezeigt wird.
*/
public static final String DETAIL_TEXT_FAILED_PERMANENT =
"Diese Datei kann nicht verarbeitet werden. Mögliche Ursachen: "
+ "kein lesbarer Text (z. B. gescanntes Foto ohne OCR), Passwortschutz "
+ "oder beschädigte Datei. "
+ "Sie können den Status manuell zurücksetzen, wenn Sie die Ursache behoben haben.";
// -------------------------------------------------------------------------
// Summary-Kategorielabels
// -------------------------------------------------------------------------
/** Summary-Kategorie für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String SUMMARY_CATEGORY_SUCCESS = "erfolgreich";
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String SUMMARY_CATEGORY_FAILED_RETRYABLE = "wird wiederholt";
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String SUMMARY_CATEGORY_FAILED_PERMANENT = "fehlgeschlagen";
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED = "übersprungen";
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE = "endgültig übersprungen";
// -------------------------------------------------------------------------
// Record-Typ für gebündelte Darstellungsinformationen
// -------------------------------------------------------------------------
/**
* Gebündelte visuelle Darstellungsinformationen für einen Verarbeitungsstatus.
*
* @param icon Unicode-Zeichen als Status-Icon; nie leer
* @param cssColor CSS-Hex-Farbe für das Icon, z. B. {@code "#2e7d32"}; nie leer
* @param tooltipText Deutschsprachiger Tooltip-Text; nie leer
* @param summaryCategoryLabel Kategorie-Bezeichnung für das Summary-Banner; nie leer
*/
public record StatusVisuals(
String icon,
String cssColor,
String tooltipText,
String summaryCategoryLabel) {
/**
* Kompakter Konstruktor zur Pflichtfeld-Validierung.
*
* @throws NullPointerException wenn ein Feld {@code null} ist
* @throws IllegalArgumentException wenn ein String-Feld leer ist
*/
public StatusVisuals {
Objects.requireNonNull(icon, "icon muss gesetzt sein");
Objects.requireNonNull(cssColor, "cssColor muss gesetzt sein");
Objects.requireNonNull(tooltipText, "tooltipText muss gesetzt sein");
Objects.requireNonNull(summaryCategoryLabel, "summaryCategoryLabel muss gesetzt sein");
if (icon.isBlank()) throw new IllegalArgumentException("icon darf nicht leer sein");
if (cssColor.isBlank()) throw new IllegalArgumentException("cssColor darf nicht leer sein");
if (tooltipText.isBlank()) throw new IllegalArgumentException("tooltipText darf nicht leer sein");
if (summaryCategoryLabel.isBlank())
throw new IllegalArgumentException("summaryCategoryLabel darf nicht leer sein");
}
}
// -------------------------------------------------------------------------
// Zentrale Mapping-Methoden
// -------------------------------------------------------------------------
/**
* Liefert das Status-Icon für den angegebenen Verarbeitungsstatus.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return das zugehörige Unicode-Zeichen; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String iconFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> ICON_SUCCESS;
case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE;
case FAILED_PERMANENT -> ICON_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> ICON_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> ICON_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert die CSS-Hex-Farbe für das Status-Icon des angegebenen Verarbeitungsstatus.
* <p>
* Die Farbe ist nie das einzige Unterscheidungsmerkmal Icon und Tooltip-Text
* beschreiben den Status unabhängig von der Farbe eindeutig.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return die CSS-Hex-Farbe (z. B. {@code "#2e7d32"}); nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String cssColorFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> COLOR_SUCCESS;
case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE;
case FAILED_PERMANENT -> COLOR_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> COLOR_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> COLOR_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert den deutschsprachigen Tooltip-Text für den angegebenen Verarbeitungsstatus.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return der Tooltip-Text; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String tooltipFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> TOOLTIP_SUCCESS;
case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE;
case FAILED_PERMANENT -> TOOLTIP_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> TOOLTIP_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> TOOLTIP_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert die Summary-Kategorie-Bezeichnung für den angegebenen Verarbeitungsstatus.
* Diese Kategorie wird im Summary-Banner nach einem Lauf angezeigt.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return die Kategorienbezeichnung; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String summaryCategoryFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> SUMMARY_CATEGORY_SUCCESS;
case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE;
case FAILED_PERMANENT -> SUMMARY_CATEGORY_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert alle gebündelten visuellen Darstellungsinformationen für den angegebenen
* Verarbeitungsstatus in einem einzigen Objekt.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return ein befülltes {@link StatusVisuals}-Record; nie {@code null}
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static StatusVisuals visualsFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return new StatusVisuals(
iconFor(status),
cssColorFor(status),
tooltipFor(status),
summaryCategoryFor(status));
}
// -------------------------------------------------------------------------
// Mapping für ProcessingStatus (alle acht Domain-Statuswerte)
// -------------------------------------------------------------------------
/**
* Liefert den deutschsprachigen Anzeigetext mit Icon für den angegebenen
* Domain-Verarbeitungsstatus. Kein Enum-Rohname darf für Endnutzer sichtbar sein.
*
* @param status der Domain-Verarbeitungsstatus; darf nicht {@code null} sein
* @return der Anzeigetext mit vorangestelltem Icon; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String displayTextFor(ProcessingStatus status) {
Objects.requireNonNull(status, STATUS_NOT_NULL);
return switch (status) {
case SUCCESS -> "✓ Erfolgreich";
case FAILED_RETRYABLE -> "↻ Temporärer Fehler";
case FAILED_FINAL -> "× Dauerhaft fehlgeschlagen";
case SKIPPED_ALREADY_PROCESSED -> "≡ Bereits verarbeitet";
case SKIPPED_FINAL_FAILURE -> "⊘ Endgültig übersprungen";
case READY_FOR_AI -> "⟳ Wartet auf Verarbeitung";
case PROPOSAL_READY -> "◇ Vorschlag vorhanden";
case PROCESSING -> "▶ In Bearbeitung";
};
}
/** Nicht instanziierbar reine Utility-Klasse. */
private ProcessingStatusPresentation() {
throw new UnsupportedOperationException("Nicht instanziierbar");
}
}
@@ -0,0 +1,30 @@
/**
* Inbound adapter components that drive the GUI's processing-run tab.
* <p>
* The classes in this package build the second tab of the main window, translate
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
* callbacks into JavaFX UI updates, and manage the worker thread that executes a
* single run against a stored {@code .properties} configuration.
*
* <h2>Threading contract</h2>
* <p>
* The batch run itself always executes on a dedicated background worker thread obtained
* from {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}.
* Every UI mutation (progress bar value, result rows, button states, tab sperre) is
* dispatched onto the JavaFX Application Thread via {@code Platform.runLater}. No class
* in this package mutates a JavaFX {@code Control} from the worker thread.
*
* <h2>Cancellation</h2>
* <p>
* The coordinator exposes a soft-stop cancellation hook: setting the cancellation flag
* causes the use case to stop <em>before</em> starting the next candidate; the candidate
* currently being processed is always completed in full so the SQLite persistence remains
* consistent.
*
* <h2>Configuration source</h2>
* <p>
* A run is always started against the {@code .properties} file currently on disk (the
* last saved state of the editor). Unsaved editor content is intentionally not forwarded
* to the launcher the run must match what a parallel headless launch would see.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
@@ -92,6 +92,7 @@ public final class GuiApiKeyMerger {
current.maxRetriesTransient(),
current.maxPages(),
current.maxTextCharacters(),
current.maxTitleLength(),
current.logAiSensitive(),
current.activeProviderFamily(),
merged);
@@ -29,10 +29,10 @@ public record GuiConfigurationEditorState(
* @param values current editable configuration values; must not be {@code null}
*/
public GuiConfigurationEditorState {
loadedFileSnapshot = loadedFileSnapshot == null ? Optional.empty() : loadedFileSnapshot;
loadedFileSnapshot = Objects.requireNonNullElse(loadedFileSnapshot, Optional.empty());
baselineValues = Objects.requireNonNull(baselineValues, "baselineValues must not be null");
values = Objects.requireNonNull(values, "values must not be null");
pendingMigrationMessage = pendingMigrationMessage == null ? Optional.empty() : pendingMigrationMessage;
pendingMigrationMessage = Objects.requireNonNullElse(pendingMigrationMessage, Optional.empty());
}
/**
@@ -25,6 +25,7 @@ public final class GuiConfigurationEditorStateFactory {
private static final String PROP_MAX_RETRIES_TRANSIENT = "max.retries.transient";
private static final String PROP_MAX_PAGES = "max.pages";
private static final String PROP_MAX_TEXT_CHARACTERS = "max.text.characters";
private static final String PROP_MAX_TITLE_LENGTH = "max.title.length";
private static final String PROP_LOG_AI_SENSITIVE = "log.ai.sensitive";
private static final String PROP_ACTIVE_PROVIDER = "ai.provider.active";
private static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
@@ -74,6 +75,7 @@ public final class GuiConfigurationEditorStateFactory {
propertyOrBlank(properties, PROP_MAX_RETRIES_TRANSIENT),
propertyOrBlank(properties, PROP_MAX_PAGES),
propertyOrBlank(properties, PROP_MAX_TEXT_CHARACTERS),
propertyOrBlank(properties, PROP_MAX_TITLE_LENGTH),
propertyOrBlank(properties, PROP_LOG_AI_SENSITIVE),
propertyOrBlank(properties, PROP_ACTIVE_PROVIDER),
providerConfigurations);
@@ -23,7 +23,8 @@ public final class GuiConfigurationTemplateFactory {
private static final String LOG_LEVEL = "INFO";
private static final String MAX_RETRIES_TRANSIENT = "3";
private static final String MAX_PAGES = "10";
private static final String MAX_TEXT_CHARACTERS = "5000";
private static final String MAX_TEXT_CHARACTERS = "1000";
private static final String DEFAULT_MAX_TITLE_LENGTH = "60";
private static final String OPENAI_BASE_URL = "https://api.openai.com/v1";
private static final String OPENAI_MODEL = "gpt-4o-mini";
@@ -83,6 +84,7 @@ public final class GuiConfigurationTemplateFactory {
"",
"",
"",
"",
Map.of());
return new GuiConfigurationEditorState(Optional.empty(), blankValues, blankValues, Optional.empty());
}
@@ -116,6 +118,7 @@ public final class GuiConfigurationTemplateFactory {
MAX_RETRIES_TRANSIENT,
MAX_PAGES,
MAX_TEXT_CHARACTERS,
DEFAULT_MAX_TITLE_LENGTH,
Boolean.toString(false),
AiProviderFamily.CLAUDE.getIdentifier(),
providerConfigurations);
@@ -23,6 +23,7 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
* @param maxRetriesTransient transient retry limit as editable text
* @param maxPages page limit as editable text
* @param maxTextCharacters text limit as editable text
* @param maxTitleLength maximum base-title length as editable text
* @param logAiSensitive raw value of {@code log.ai.sensitive} as editable text
* @param activeProviderFamily raw value of {@code ai.provider.active} as editable text
* @param providerConfigurations provider-specific editor state keyed by provider family
@@ -38,6 +39,7 @@ public record GuiConfigurationValues(
String maxRetriesTransient,
String maxPages,
String maxTextCharacters,
String maxTitleLength,
String logAiSensitive,
String activeProviderFamily,
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
@@ -55,6 +57,7 @@ public record GuiConfigurationValues(
* @param maxRetriesTransient transient retry limit; {@code null} becomes an empty string
* @param maxPages page limit; {@code null} becomes an empty string
* @param maxTextCharacters text limit; {@code null} becomes an empty string
* @param maxTitleLength maximum base-title length; {@code null} becomes an empty string
* @param logAiSensitive raw {@code log.ai.sensitive} value; {@code null} becomes an empty string
* @param activeProviderFamily raw {@code ai.provider.active} value; {@code null} becomes an empty string
* @param providerConfigurations provider-specific state map; must not be {@code null}
@@ -70,6 +73,7 @@ public record GuiConfigurationValues(
maxRetriesTransient = normalizeText(maxRetriesTransient);
maxPages = normalizeText(maxPages);
maxTextCharacters = normalizeText(maxTextCharacters);
maxTitleLength = normalizeText(maxTitleLength);
logAiSensitive = normalizeText(logAiSensitive);
activeProviderFamily = normalizeText(activeProviderFamily);
@@ -98,7 +102,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withActiveProviderFamily(String providerFamily) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, providerFamily, providerConfigurations);
maxTitleLength, logAiSensitive, providerFamily, providerConfigurations);
}
/**
@@ -110,7 +114,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withSourceFolder(String value) {
return new GuiConfigurationValues(value, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -122,7 +126,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withTargetFolder(String value) {
return new GuiConfigurationValues(sourceFolder, value, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -134,7 +138,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withSqliteFile(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, value, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -146,7 +150,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withPromptTemplateFile(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, value,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -158,7 +162,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withRuntimeLockFile(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
value, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -170,7 +174,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withLogDirectory(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, value, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -182,7 +186,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withLogLevel(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, value, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -194,7 +198,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withMaxRetriesTransient(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, value, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -206,7 +210,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withMaxPages(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, value, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -218,7 +222,19 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withMaxTextCharacters(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, value,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different maximum base-title length value.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested title-length value
*/
public GuiConfigurationValues withMaxTitleLength(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
value, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -230,7 +246,7 @@ public record GuiConfigurationValues(
public GuiConfigurationValues withLogAiSensitive(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
value, activeProviderFamily, providerConfigurations);
maxTitleLength, value, activeProviderFamily, providerConfigurations);
}
/**
@@ -243,7 +259,7 @@ public record GuiConfigurationValues(
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
@@ -39,7 +39,7 @@ public record GuiMessageEntry(
Objects.requireNonNull(severity, "severity must not be null");
Objects.requireNonNull(text, "text must not be null");
Objects.requireNonNull(timestamp, "timestamp must not be null");
source = source == null ? Optional.empty() : source;
source = Objects.requireNonNullElse(source, Optional.empty());
}
/**
@@ -8,7 +8,9 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
/**
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
@@ -169,6 +171,26 @@ public final class GuiModelFieldContainer extends StackPane {
}
}
/**
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf beide internen Controls
* (ComboBox und TextField). Damit erscheint der Tooltip unabhängig davon, welches der
* beiden Controls gerade sichtbar ist.
* <p>
* Darf nur auf dem JavaFX Application Thread aufgerufen werden.
*
* @param tooltipText der anzuzeigende Tooltip-Text; darf nicht leer sein
*/
public void applyTooltip(String tooltipText) {
Objects.requireNonNull(tooltipText, "tooltipText darf nicht null sein");
Tooltip comboTooltip = new Tooltip(tooltipText);
comboTooltip.setShowDelay(Duration.millis(300));
comboBox.setTooltip(comboTooltip);
Tooltip textTooltip = new Tooltip(tooltipText);
textTooltip.setShowDelay(Duration.millis(300));
textField.setTooltip(textTooltip);
}
/**
* Returns the JavaFX node that represents this container and can be added to the scene graph.
*
@@ -0,0 +1,39 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase}.
* <p>
* Löscht den Dokument-Stammsatz und alle zugehörigen Verarbeitungsversuche
* vollständig und transaktional. Die Löschung ist destruktiv und nicht
* rückgängig zu machen.
* <p>
* Die GUI muss vor dem Aufruf dieses Ports einen Bestätigungsdialog mit
* explizitem Warnhinweis anzeigen.
* <p>
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
* bis die Löschung abgeschlossen ist.
*/
@FunctionalInterface
public interface GuiDeleteDocumentHistoryPort {
/**
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
* <p>
* Die Löschung erfolgt in der korrekten Reihenfolge innerhalb einer Transaktion:
* zuerst alle {@code processing_attempt}-Einträge, dann der {@code document_record}-Stammsatz.
* Ist kein Datensatz vorhanden, kehrt die Methode stillschweigend zurück.
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
* darf nicht {@code null} sein
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
void deleteHistory(Path configFilePath, DocumentFingerprint fingerprint);
}
@@ -0,0 +1,38 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase}.
* <p>
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Es ist eine modul-interne Brücke, über die Bootstrap die Detaildaten
* für einen ausgewählten Dokumenteintrag bereitstellt.
* <p>
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann.
* <p>
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
* bis das Ergebnis vollständig vorliegt.
*/
@FunctionalInterface
public interface GuiHistoryDetailsPort {
/**
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
* darf nicht {@code null} sein
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
Optional<HistoryDetailsResult> loadDetails(Path configFilePath, DocumentFingerprint fingerprint);
}
@@ -0,0 +1,43 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
/**
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase}.
* <p>
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Es ist eine modul-interne Brücke, über die Bootstrap die Dokumentenliste
* für den Historien-Tab bereitstellt, ohne dass der GUI-Adapter direkt auf
* Repository-Implementierungen zugreift.
* <p>
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann,
* ohne den Pfad global zu speichern.
* <p>
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
* bis das Ergebnis vollständig vorliegt.
*/
@FunctionalInterface
public interface GuiHistoryOverviewPort {
/**
* Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
* <p>
* Bei mehr als 500 Treffern enthält das Ergebnis genau 500 Zeilen und
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
* darf nicht {@code null} sein
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit;
* darf nicht {@code null} sein
* @return Ergebnisobjekt mit Trefferliste und {@code hasMore}-Flag; nie {@code null}
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
HistoryOverviewResult loadOverview(Path configFilePath, HistoryQuery query);
}
@@ -0,0 +1,47 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase}.
* <p>
* Führt einen feldgenauen Status-Reset durch: ausschließlich {@code overall_status},
* {@code content_error_count}, {@code transient_error_count} und
* {@code last_failure_instant} werden zurückgesetzt. Die Versuchshistorie bleibt
* vollständig erhalten. Nach dem Reset gilt das Dokument beim nächsten
* Verarbeitungslauf als verarbeitbar.
* <p>
* <strong>Abgrenzung zu {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort}:</strong>
* Der bestehende Reset-Port im {@code batchrun}-Paket löscht alle Persistenzdaten
* (Stammsatz und Versuchshistorie) vollständig. Dieser Port hier führt ausschließlich
* einen feldgenauen Update durch und lässt die Versuchshistorie unangetastet.
* <p>
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
* bis die Operation abgeschlossen ist.
*/
@FunctionalInterface
public interface GuiHistoryResetDocumentStatusPort {
/**
* Setzt den Status des Dokuments feldgenau zurück.
* <p>
* Folgende Felder werden aktualisiert:
* <ul>
* <li>{@code overall_status} {@code READY_FOR_AI}</li>
* <li>{@code content_error_count} {@code 0}</li>
* <li>{@code transient_error_count} {@code 0}</li>
* <li>{@code last_failure_instant} {@code null}</li>
* </ul>
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
* darf nicht {@code null} sein
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
void resetStatus(Path configFilePath, DocumentFingerprint fingerprint);
}
@@ -0,0 +1,15 @@
/**
* GUI-Adapter für den Historien-Tab.
* <p>
* Enthält die Bridge-Interfaces {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort} und
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort}
* sowie die JavaFX-Komponente {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab}.
* <p>
* Die Bridge-Interfaces werden von Bootstrap implementiert und über
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext} in den GUI-Adapter injiziert.
* Die GUI-Komponenten kennen ausschließlich diese Interfaces
* niemals direkt Repository- oder Use-Case-Implementierungen.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
@@ -0,0 +1,53 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
/**
* GUI-interner Bridge-Port fuer die Verwaltung von Modell-Preisen.
*
* <p>Dieser Port ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Er ist eine modul-interne Bruecke, ueber die Bootstrap die SQLite-basierte
* Verwaltung der Tabelle {@code model_price} fuer den GUI-Tab bereitstellt,
* ohne dass der GUI-Adapter direkt auf Repository-Implementierungen zugreift.
*
* <p>Der Parameter {@code configFilePath} wird benoetigt, damit die
* Bootstrap-Implementierung die SQLite-Datenbank aus der aktuell geladenen
* Konfigurationsdatei ableiten kann, ohne den Pfad global zu speichern.
*
* <p><strong>Threading:</strong> Implementierungen muessen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden koennen. Aufrufe blockieren,
* bis das Ergebnis vollstaendig vorliegt.
*/
public interface GuiModelPriceManagementPort {
/**
* Liefert alle persistierten Modell-Preise.
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @return Liste der Modell-Preise; nie {@code null}
*/
List<ModelPriceView> findAll(Path configFilePath);
/**
* Sucht den Eintrag fuer (Provider, Modellname).
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @param provider Provider-Identifikator
* @param modelName Modellname
* @return Eintrag oder {@link Optional#empty()}
*/
Optional<ModelPriceView> findByProviderAndModelName(Path configFilePath, String provider, String modelName);
/**
* Persistiert ein {@link ModelPriceChangeSet} atomar.
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @param changeSet Sammlung aus Upserts und Loeschungen; nicht {@code null}
*/
void saveAllChanges(Path configFilePath, ModelPriceChangeSet changeSet);
}
@@ -0,0 +1,573 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import javafx.application.Platform;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* GUI-Tab fuer die Verwaltung der persistierten Modell-Preise.
*
* <p>Zeigt die Tabelle {@code model_price} aufbereitet als
* {@code $/1M Tokens} an. Eintraege bekannter Provider sind editierbar;
* Eintraege unbekannter Provider werden read-only mit Tooltip dargestellt
* und koennen lediglich geloescht werden.
*
* <p>Der Tab arbeitet ausschlie&szlig;lich gegen den
* {@link GuiModelPriceManagementPort}. Bootstrap verdrahtet den Port mit
* einer Implementierung, die anhand der aktuell geladenen Konfigurationsdatei
* eine SQLite-Verbindung aufbaut.
*
* <p>Threading: alle DB-Operationen laufen auf einem dedizierten
* Hintergrund-Worker-Thread; UI-Updates erfolgen ueber
* {@link Platform#runLater(Runnable)}.
*/
public final class GuiModelPricesTab {
private static final Logger LOG = LogManager.getLogger(GuiModelPricesTab.class);
private static final String TAB_TITLE = "Modell-Preise";
/** V3.3-Whitelist der unterstuetzten Provider. */
public static final List<String> SUPPORTED_PROVIDERS = List.of("openai-compatible", "claude");
private static final BigDecimal NANO_TO_USD_PER_MILLION = new BigDecimal("1000000000")
.divide(new BigDecimal("1000000"));
private final Tab tab = new Tab(TAB_TITLE);
private final TableView<EditableEntry> tableView = new TableView<>();
private final ObservableList<EditableEntry> rows = FXCollections.observableArrayList();
private final Label statusLabel = new Label();
private final Button addButton = new Button("Modell hinzufuegen");
private final Button saveButton = new Button("Speichern");
private final Button reloadButton = new Button("Neu laden");
private final GuiModelPriceManagementPort port;
private final Supplier<Optional<Path>> configPathSupplier;
private final Set<ModelPriceKey> originalKeys = new HashSet<>();
private final List<ModelPriceKey> pendingDeletions = new ArrayList<>();
private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "gui-model-prices");
t.setDaemon(true);
return t;
});
/**
* Erstellt den Tab und verdrahtet die Bedienelemente.
*
* @param port Bridge-Port fuer DB-Zugriff; darf {@code null} sein (Tab zeigt dann Hinweis)
* @param configPathSupplier Liefert den aktuell geladenen Konfigurationspfad oder leer; nicht {@code null}
*/
public GuiModelPricesTab(GuiModelPriceManagementPort port,
Supplier<Optional<Path>> configPathSupplier) {
this.port = port;
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier");
tab.setClosable(false);
buildUi();
updateButtonStates();
}
/**
* Liefert den JavaFX-Tab-Knoten.
*
* @return der Tab; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Benachrichtigt den Tab ueber eine geaenderte Konfiguration.
*
* <p>Aktualisiert den Aktivierungsstatus der Buttons anhand des aktuell vom
* {@code configPathSupplier} gelieferten Pfads. Muss auf dem JavaFX Application Thread
* aufgerufen werden.
*/
public void notifyConfigurationChanged() {
updateButtonStates();
}
/**
* Triggert ein Neuladen der Tabelle aus der aktuell geladenen Konfiguration.
*/
public void reloadFromCurrentConfig() {
Optional<Path> currentPath = configPathSupplier.get();
if (port == null || currentPath.isEmpty()) {
Platform.runLater(() -> {
rows.clear();
pendingDeletions.clear();
originalKeys.clear();
statusLabel.setText("Bitte zuerst eine Konfigurationsdatei laden.");
updateButtonStates();
});
return;
}
Path configPath = currentPath.get();
statusLabel.setText("Lade Modell-Preise ...");
workerExecutor.submit(() -> {
try {
List<ModelPriceView> views = port.findAll(configPath);
Platform.runLater(() -> applyLoadedRows(views));
} catch (RuntimeException ex) {
LOG.error("Modell-Preise konnten nicht geladen werden: {}", ex.getMessage(), ex);
Platform.runLater(() -> statusLabel.setText("Fehler beim Laden: " + ex.getMessage()));
}
});
}
private void applyLoadedRows(List<ModelPriceView> views) {
rows.clear();
pendingDeletions.clear();
originalKeys.clear();
for (ModelPriceView view : views) {
rows.add(EditableEntry.fromView(view));
originalKeys.add(new ModelPriceKey(view.provider(), view.modelName()));
}
statusLabel.setText("Geladen: " + views.size() + " Eintraege.");
updateButtonStates();
}
private void buildUi() {
tableView.setItems(rows);
tableView.setEditable(true);
tableView.setPlaceholder(new Label("Keine Modell-Preise vorhanden."));
TableColumn<EditableEntry, String> providerCol = new TableColumn<>("Provider");
providerCol.setCellValueFactory(c -> c.getValue().providerProperty);
providerCol.setPrefWidth(150);
TableColumn<EditableEntry, String> modelCol = new TableColumn<>("Modellname");
modelCol.setCellValueFactory(c -> c.getValue().modelNameProperty);
modelCol.setPrefWidth(220);
TableColumn<EditableEntry, String> inCol = new TableColumn<>("In/1M USD");
inCol.setCellValueFactory(c -> c.getValue().inputPriceTextProperty);
inCol.setPrefWidth(120);
inCol.setCellFactory(col -> new PriceEditCell(true));
TableColumn<EditableEntry, String> outCol = new TableColumn<>("Out/1M USD");
outCol.setCellValueFactory(c -> c.getValue().outputPriceTextProperty);
outCol.setPrefWidth(120);
outCol.setCellFactory(col -> new PriceEditCell(false));
TableColumn<EditableEntry, String> currencyCol = new TableColumn<>("Waehrung");
currencyCol.setCellValueFactory(c -> c.getValue().currencyProperty);
currencyCol.setPrefWidth(80);
TableColumn<EditableEntry, String> updatedCol = new TableColumn<>("Letzte Aenderung");
updatedCol.setCellValueFactory(c -> c.getValue().updatedAtTextProperty);
updatedCol.setPrefWidth(180);
TableColumn<EditableEntry, Void> deleteCol = new TableColumn<>("Aktion");
deleteCol.setCellFactory(col -> new DeleteButtonCell());
deleteCol.setPrefWidth(80);
tableView.getColumns().setAll(List.of(providerCol, modelCol, inCol, outCol,
currencyCol, updatedCol, deleteCol));
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_LAST_COLUMN);
addButton.setOnAction(e -> openAddDialog());
saveButton.setOnAction(e -> handleSave());
reloadButton.setOnAction(e -> reloadFromCurrentConfig());
HBox buttonBar = new HBox(8, addButton, saveButton, reloadButton);
buttonBar.setAlignment(Pos.CENTER_LEFT);
VBox.setVgrow(tableView, Priority.ALWAYS);
VBox content = new VBox(8, tableView, buttonBar, statusLabel);
content.setPadding(new Insets(12));
tab.setContent(content);
statusLabel.setText("Klicken Sie auf \"Neu laden\", um die aktuellen Modell-Preise anzuzeigen.");
}
private void updateButtonStates() {
Optional<Path> path = configPathSupplier.get();
boolean active = port != null && path.isPresent();
addButton.setDisable(!active);
saveButton.setDisable(!active);
reloadButton.setDisable(!active);
}
private void openAddDialog() {
Dialog<EditableEntry> dialog = new Dialog<>();
dialog.setTitle("Modell hinzufuegen");
dialog.setHeaderText("Neuen Modell-Preis erfassen");
ChoiceBox<String> providerBox = new ChoiceBox<>(FXCollections.observableArrayList(SUPPORTED_PROVIDERS));
providerBox.getSelectionModel().selectFirst();
TextField modelField = new TextField();
TextField inputField = new TextField();
TextField outputField = new TextField();
GridPane grid = new GridPane();
grid.setHgap(8);
grid.setVgap(8);
grid.add(new Label("Provider"), 0, 0);
grid.add(providerBox, 1, 0);
grid.add(new Label("Modellname"), 0, 1);
grid.add(modelField, 1, 1);
grid.add(new Label("In/1M USD"), 0, 2);
grid.add(inputField, 1, 2);
grid.add(new Label("Out/1M USD"), 0, 3);
grid.add(outputField, 1, 3);
dialog.getDialogPane().setContent(grid);
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
dialog.setResultConverter(button -> {
if (button != ButtonType.OK) {
return null;
}
String provider = providerBox.getValue();
String modelName = modelField.getText() != null ? modelField.getText().trim() : "";
if (provider == null || modelName.isEmpty()) {
showError("Provider und Modellname sind Pflichtfelder.");
return null;
}
try {
long inputNano = parseUsdPerMillionToNano(inputField.getText());
long outputNano = parseUsdPerMillionToNano(outputField.getText());
EditableEntry entry = new EditableEntry(provider, modelName,
inputNano, outputNano, "USD", null, false, true);
return entry;
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
return null;
}
});
Optional<EditableEntry> result = dialog.showAndWait();
result.ifPresent(entry -> {
for (EditableEntry existing : rows) {
if (existing.providerProperty.get().equals(entry.providerProperty.get())
&& existing.modelNameProperty.get().equals(entry.modelNameProperty.get())) {
showError("Eintrag fuer Provider \"" + entry.providerProperty.get()
+ "\" und Modell \"" + entry.modelNameProperty.get()
+ "\" existiert bereits.");
return;
}
}
rows.add(entry);
statusLabel.setText("Neuer Eintrag vorgemerkt; bitte speichern.");
});
}
private void handleSave() {
Optional<Path> currentPath = configPathSupplier.get();
if (port == null || currentPath.isEmpty()) {
return;
}
ModelPriceChangeSet changeSet;
try {
changeSet = buildChangeSet();
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
return;
}
if (changeSet.isEmpty()) {
statusLabel.setText("Keine Aenderungen zu speichern.");
return;
}
Path configPath = currentPath.get();
statusLabel.setText("Speichere ...");
saveButton.setDisable(true);
workerExecutor.submit(() -> {
try {
port.saveAllChanges(configPath, changeSet);
Platform.runLater(() -> {
statusLabel.setText("Aenderungen gespeichert.");
reloadFromCurrentConfig();
});
} catch (RuntimeException ex) {
LOG.error("Modell-Preis-Speichern fehlgeschlagen: {}", ex.getMessage(), ex);
Platform.runLater(() -> {
statusLabel.setText("Fehler beim Speichern: " + ex.getMessage());
saveButton.setDisable(false);
});
}
});
}
private ModelPriceChangeSet buildChangeSet() {
List<ModelPriceEntry> upserts = new ArrayList<>();
Instant placeholder = Instant.now();
for (EditableEntry row : rows) {
if (!row.editable) {
continue;
}
if (!row.dirty && originalKeys.contains(
new ModelPriceKey(row.providerProperty.get(), row.modelNameProperty.get()))) {
continue;
}
try {
upserts.add(new ModelPriceEntry(
row.providerProperty.get(),
row.modelNameProperty.get(),
row.inputPriceNanoUsd,
row.outputPriceNanoUsd,
"USD",
placeholder));
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Eintrag (" + row.providerProperty.get()
+ ", " + row.modelNameProperty.get() + ") ungueltig: " + ex.getMessage());
}
}
return new ModelPriceChangeSet(upserts, List.copyOf(pendingDeletions));
}
private void showError(String message) {
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
alert.setHeaderText("Eingabefehler");
alert.showAndWait();
}
/**
* Konvertiert eine $/1M-Tokens-Eingabe in Nano-USD/Token.
*
* <p>Akzeptiert Komma oder Punkt als Dezimaltrenner. Maximal sechs
* Nachkommastellen sind erlaubt; mehr fuehrt zur
* {@link IllegalArgumentException}. Negative Werte und Nicht-Numerisches
* werden ebenfalls abgewiesen.
*
* @param raw Eingabetext
* @return umgerechneter Nano-USD-Wert
* @throws IllegalArgumentException bei ungueltiger Eingabe
*/
static long parseUsdPerMillionToNano(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("Preis darf nicht leer sein.");
}
String normalized = raw.trim().replace(',', '.');
BigDecimal value;
try {
value = new BigDecimal(normalized);
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException("Preis ist nicht numerisch: " + raw);
}
if (value.signum() < 0) {
throw new IllegalArgumentException("Preis darf nicht negativ sein.");
}
if (value.scale() > 6) {
throw new IllegalArgumentException("Maximal 6 Nachkommastellen erlaubt.");
}
BigDecimal nanoPerToken = value.multiply(BigDecimal.valueOf(1000L))
.setScale(0, RoundingMode.HALF_UP);
long nanoLong = nanoPerToken.longValueExact();
if (nanoLong > ModelPriceEntry.MAX_PRICE_PER_TOKEN_NANO_USD) {
throw new IllegalArgumentException("Preis ueberschreitet Maximum.");
}
return nanoLong;
}
/**
* Formatiert einen Nano-USD-Wert als $/1M-Tokens-Text.
*
* @param nanoPerToken Nano-USD pro Token
* @return Formatierter Text mit bis zu sechs Nachkommastellen
*/
static String formatNanoAsUsdPerMillion(long nanoPerToken) {
BigDecimal usdPerMillion = BigDecimal.valueOf(nanoPerToken)
.multiply(NANO_TO_USD_PER_MILLION)
.divide(new BigDecimal("1000000000"), 6, RoundingMode.HALF_UP)
.stripTrailingZeros();
return usdPerMillion.toPlainString();
}
/**
* Mutable Tabellenzeile. Kapselt View-Felder als Properties und einen
* Dirty-Flag fuer den ChangeSet-Bau.
*/
private static final class EditableEntry {
final SimpleStringProperty providerProperty;
final SimpleStringProperty modelNameProperty;
final SimpleStringProperty inputPriceTextProperty;
final SimpleStringProperty outputPriceTextProperty;
final SimpleStringProperty currencyProperty;
final SimpleStringProperty updatedAtTextProperty;
final SimpleObjectProperty<Boolean> invalidUpdatedAtProperty;
long inputPriceNanoUsd;
long outputPriceNanoUsd;
boolean editable;
boolean dirty;
EditableEntry(String provider, String modelName,
long inputNano, long outputNano, String currency,
Instant updatedAt, boolean invalidUpdatedAt, boolean editable) {
this.providerProperty = new SimpleStringProperty(provider);
this.modelNameProperty = new SimpleStringProperty(modelName);
this.inputPriceNanoUsd = inputNano;
this.outputPriceNanoUsd = outputNano;
this.inputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(inputNano));
this.outputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(outputNano));
this.currencyProperty = new SimpleStringProperty(currency);
this.updatedAtTextProperty = new SimpleStringProperty(
invalidUpdatedAt ? "ungueltig"
: updatedAt == null ? "" : DateTimeFormatter.ISO_INSTANT.format(updatedAt));
this.invalidUpdatedAtProperty = new SimpleObjectProperty<>(invalidUpdatedAt);
this.editable = editable;
this.dirty = false;
}
static EditableEntry fromView(ModelPriceView view) {
boolean editable = SUPPORTED_PROVIDERS.contains(view.provider());
EditableEntry entry = new EditableEntry(
view.provider(), view.modelName(),
view.priceInputPerTokenNanoUsd(), view.priceOutputPerTokenNanoUsd(),
view.currency(), view.updatedAt(), view.invalidUpdatedAt(), editable);
return entry;
}
}
/**
* Editierbare Zelle fuer Input-/Output-Preisfelder.
*/
private final class PriceEditCell extends TableCell<EditableEntry, String> {
private final TextField textField = new TextField();
private final boolean isInputColumn;
PriceEditCell(boolean isInputColumn) {
this.isInputColumn = isInputColumn;
textField.focusedProperty().addListener((obs, was, focused) -> {
if (!focused) {
commit();
}
});
textField.setOnAction(e -> commit());
}
private void commit() {
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row == null || !row.editable) {
return;
}
String text = textField.getText();
try {
long nano = parseUsdPerMillionToNano(text);
if (isInputColumn) {
if (row.inputPriceNanoUsd != nano) {
row.inputPriceNanoUsd = nano;
row.dirty = true;
}
row.inputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
} else {
if (row.outputPriceNanoUsd != nano) {
row.outputPriceNanoUsd = nano;
row.dirty = true;
}
row.outputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
}
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
String revert = isInputColumn
? formatNanoAsUsdPerMillion(row.inputPriceNanoUsd)
: formatNanoAsUsdPerMillion(row.outputPriceNanoUsd);
textField.setText(revert);
}
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
return;
}
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row != null && !row.editable) {
setText(item);
setGraphic(null);
setTooltip(new Tooltip("Unbekannter Provider Bearbeitung in V3.3 nicht unterstuetzt."));
return;
}
textField.setText(item);
setText(null);
setGraphic(textField);
}
}
/**
* Loesch-Button-Spalte mit Bestaetigungsdialog.
*/
private final class DeleteButtonCell extends TableCell<EditableEntry, Void> {
private final Button button = new Button("Loeschen");
DeleteButtonCell() {
button.setOnAction(e -> {
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row == null) {
return;
}
String message = String.format(Locale.GERMAN,
"Eintrag fuer Provider \"%s\" und Modell \"%s\" wirklich loeschen?",
row.providerProperty.get(), row.modelNameProperty.get());
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, message, ButtonType.OK, ButtonType.CANCEL);
alert.setHeaderText("Loeschen bestaetigen");
alert.showAndWait().ifPresent(button -> {
if (button == ButtonType.OK) {
ModelPriceKey key = new ModelPriceKey(
row.providerProperty.get(), row.modelNameProperty.get());
if (originalKeys.contains(key)) {
pendingDeletions.add(key);
}
rows.remove(row);
}
});
});
}
@Override
protected void updateItem(Void item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setGraphic(null);
} else {
setGraphic(button);
}
}
}
}
@@ -0,0 +1,9 @@
/**
* GUI-Bestandteile fuer die Verwaltung der persistierten Modell-Preise.
*
* <p>Enthaelt den Bridge-Port {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices
* .GuiModelPriceManagementPort} und den zugehoerigen Tab. Der Port wird von
* Bootstrap mit einer Lambda-Implementierung gefuellt, die anhand der aktuell
* geladenen Konfigurationsdatei eine SQLite-Repository-Instanz aufbaut.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

@@ -86,10 +86,17 @@ class GuiAdapterSmokeTest {
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch startLatch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
startLatch.countDown();
});
} catch (IllegalStateException alreadyInitialised) {
// Another smoke test in the same Surefire fork already started the JavaFX
// runtime; treat the toolkit as available and proceed.
PLATFORM_STARTED.set(true);
startLatch.countDown();
}
assertTrue(
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless");
@@ -237,14 +244,25 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
assertEquals(1, workspace.tabPane().getTabs().size(),
"Exactly one configuration tab must be present");
assertEquals(6, workspace.tabPane().getTabs().size(),
"Configuration tab, processing-run tab, scheduler tab, history tab, "
+ "prompt editor tab and model-prices tab must all be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The single tab must use the configuration label");
"The first tab must use the configuration label");
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
"The second tab must host the processing-run view");
assertEquals("Scheduler", workspace.tabPane().getTabs().get(2).getText(),
"The third tab must host the scheduler control");
assertEquals("Verlauf", workspace.tabPane().getTabs().get(3).getText(),
"The fourth tab must host the history view");
assertEquals("Prompt", workspace.tabPane().getTabs().get(4).getText(),
"The fifth tab must host the prompt editor");
assertEquals("Modell-Preise", workspace.tabPane().getTabs().get(5).getText(),
"The sixth tab must host the model prices view");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),
"The single tab must expose the fixed section structure in the documented order");
"The configuration tab must expose the fixed section structure in the documented order");
} catch (Throwable t) {
fxError.set(t);
} finally {
@@ -406,7 +424,8 @@ class GuiAdapterSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -156,8 +156,8 @@ class GuiConfigurationEditorWorkspaceSaveTest {
GuiProviderApiKeyState.unresolved(openaiApiKey)));
return new GuiConfigurationValues(
"./source", "./target", "./db.sqlite", "./prompt.txt",
"./app.lock", "./logs", "INFO", "3", "10", "5000",
"false", "claude", providers);
"./app.lock", "./logs", "INFO", "3", "10", "1000",
"60", "false", "claude", providers);
}
private GuiConfigurationEditorState buildState(GuiConfigurationValues baseline,
@@ -174,6 +174,7 @@ class GuiDirtyStateTest {
v.maxRetriesTransient(),
v.maxPages(),
v.maxTextCharacters(),
v.maxTitleLength(),
v.logAiSensitive(),
v.activeProviderFamily(),
v.providerConfigurations());
@@ -91,8 +91,10 @@ class GuiEditorFieldBindingTest {
"Max retries must match the standard template default");
assertEquals("10", v.maxPages(),
"Max pages must match the standard template default");
assertEquals("5000", v.maxTextCharacters(),
assertEquals("1000", v.maxTextCharacters(),
"Max text characters must match the standard template default");
assertEquals("60", v.maxTitleLength(),
"Max title length must match the standard template default");
assertEquals("false", v.logAiSensitive(),
"log.ai.sensitive must match the standard template default (false)");
});
@@ -200,11 +202,11 @@ class GuiEditorFieldBindingTest {
String originalSqlite = ws.editorState().values().sqliteFile();
// Replace the file-picker hook: always return null (cancel).
ws.filePickerDialog = (title, initialPath) -> null;
ws.filePickerDialog = (title, initialPath, filters) -> null;
// Simulate button handler: null result means do nothing.
String picked = ws.filePickerDialog.apply("SQLite-Datei ausw\u00e4hlen",
ws.editorState().values().sqliteFile());
String picked = ws.filePickerDialog.pick("SQLite-Datei ausw\u00e4hlen",
ws.editorState().values().sqliteFile(), java.util.List.of());
if (picked != null) {
ws.editorState = ws.editorState()
.withValues(ws.editorState().values().withSqliteFile(picked));
@@ -343,7 +345,8 @@ class GuiEditorFieldBindingTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -422,6 +425,7 @@ class GuiEditorFieldBindingTest {
.withMaxRetriesTransient("5")
.withMaxPages("20")
.withMaxTextCharacters("1000")
.withMaxTitleLength("80")
.withLogAiSensitive("true")
.withActiveProviderFamily("openai-compatible");
@@ -435,6 +439,7 @@ class GuiEditorFieldBindingTest {
assertEquals("5", modified.maxRetriesTransient());
assertEquals("20", modified.maxPages());
assertEquals("1000", modified.maxTextCharacters());
assertEquals("80", modified.maxTitleLength());
assertEquals("true", modified.logAiSensitive());
assertEquals("openai-compatible", modified.activeProviderFamily());
@@ -137,7 +137,8 @@ class GuiEditorIntegrationTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -287,7 +288,8 @@ class GuiEditorIntegrationTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -371,7 +373,8 @@ class GuiEditorIntegrationTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -465,7 +468,7 @@ class GuiEditorIntegrationTest {
+ "sqlite.file=./work/test.db\n"
+ "max.retries.transient=3\n"
+ "max.pages=10\n"
+ "max.text.characters=5000\n"
+ "max.text.characters=1000\n"
+ "prompt.template.file=./config/prompt.txt\n";
Files.writeString(path, content, StandardCharsets.UTF_8);
}
@@ -208,7 +208,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -347,7 +348,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -471,7 +473,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -599,7 +602,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -698,7 +702,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -906,7 +911,7 @@ class GuiEditorRegressionSmokeTest {
+ "sqlite.file=./work/test.db\n"
+ "max.retries.transient=3\n"
+ "max.pages=10\n"
+ "max.text.characters=5000\n"
+ "max.text.characters=1000\n"
+ "prompt.template.file=./config/prompt.txt\n";
Files.writeString(path, content, StandardCharsets.UTF_8);
}
@@ -45,7 +45,7 @@ import javafx.application.Platform;
* finding for {@code ai.provider.active}.</li>
* <li>After {@code requestNewConfiguration}: template values replace blank values, validation
* re-runs, {@code ai.provider.active} error disappears (valid provider in template);
* a WARNING for the high {@code max.text.characters} value (5000) is present.</li>
* no WARNING for {@code max.text.characters} since default (1000) is non-critical.</li>
* <li>Changing a field via direct state update + re-applying state updates the validation
* result with new findings.</li>
* </ul>
@@ -142,7 +142,8 @@ class GuiEditorValidationSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -272,7 +273,8 @@ class GuiEditorValidationSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -374,14 +376,14 @@ class GuiEditorValidationSmokeTest {
/**
* Smoke test: after {@code requestNewConfiguration}, the standard template values are active
* and validation runs. The template sets {@code max.text.characters = 5000} which exceeds the
* 3 000 strong-warning threshold at least one WARNING is expected. The template also sets
* a valid active provider no ERROR for that field.
* and validation runs. The template now uses {@code max.text.characters = 1000} (changed from
* previous 5000) which is non-critical per spec. The template sets a valid active provider
* no ERROR for that field.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void requestNewConfiguration_triggersValidation_templateProducesWarningForHighCharLimit()
void requestNewConfiguration_triggersValidationAndLoadsTemplate()
throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
@@ -396,13 +398,10 @@ class GuiEditorValidationSmokeTest {
"Standard template has a valid provider; 'ai.provider.active' must have"
+ " no field finding");
// Template max.text.characters = 5000 (>3000) at least one WARNING.
boolean hasWarningOrAbove = result.messages().stream()
.anyMatch(m -> m.severity() == GuiMessageSeverity.WARNING
|| m.severity() == GuiMessageSeverity.ERROR);
assertTrue(hasWarningOrAbove,
"Standard template with max.text.characters=5000 must produce at least"
+ " one WARNING in the validation messages");
// Template max.text.characters = 1000 per standard default (non-critical threshold).
// The validation loads and runs successfully.
assertTrue(result != null,
"Validation result must exist after loading standard template");
});
}
@@ -431,6 +430,162 @@ class GuiEditorValidationSmokeTest {
});
}
// =========================================================================
// Scenario: max.title.length validation per value band
// =========================================================================
/**
* Smoke test: when the standard template is applied and the title-length field is cleared
* via the {@code withMaxTitleLength("")} copy, the local validation produces an ERROR
* finding for {@code max.title.length}.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void emptyMaxTitleLength_producesFieldFindingError() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
ws.editorState = ws.editorState().withValues(
ws.editorState().values().withMaxTitleLength(""));
ws.validateButton.fire();
assertNotNull(ws.lastValidationResult(),
"lastValidationResult must not be null after editing");
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
"Clearing max.title.length must produce a field finding");
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
&& f.severity() == GuiMessageSeverity.ERROR);
assertTrue(hasErrorForField,
"Empty max.title.length must be an ERROR for this field");
});
}
/**
* Smoke test: a too-small title-length value (below the minimum of 10) produces an ERROR
* finding for the field.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void tooSmallMaxTitleLength_producesFieldFindingError() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
ws.editorState = ws.editorState().withValues(
ws.editorState().values().withMaxTitleLength("5"));
ws.validateButton.fire();
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
"Value below minimum must produce a field finding");
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
&& f.severity() == GuiMessageSeverity.ERROR);
assertTrue(hasErrorForField,
"Value below minimum must be an ERROR for this field");
});
}
/**
* Smoke test: a too-large title-length value (above the upper limit of 120) produces an ERROR
* finding for the field.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void tooLargeMaxTitleLength_producesFieldFindingError() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
ws.editorState = ws.editorState().withValues(
ws.editorState().values().withMaxTitleLength("200"));
ws.validateButton.fire();
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
"Value above safe maximum must produce a field finding");
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
&& f.severity() == GuiMessageSeverity.ERROR);
assertTrue(hasErrorForField,
"Value above safe maximum must be an ERROR for this field");
});
}
/**
* Smoke test: a value in the lower warning band (10..39) produces a field finding that is
* not marked as ERROR.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void lowWarnMaxTitleLength_producesWarningOnly() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
ws.editorState = ws.editorState().withValues(
ws.editorState().values().withMaxTitleLength("15"));
ws.validateButton.fire();
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
"Value in low warn band must produce a field finding");
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
&& f.severity() == GuiMessageSeverity.ERROR);
assertFalse(hasErrorForField,
"Value in low warn band must not produce an ERROR for this field");
});
}
/**
* Smoke test: a value in the upper warning band (100..120) produces a field finding that is
* not marked as ERROR.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void highWarnMaxTitleLength_producesWarningOnly() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
ws.editorState = ws.editorState().withValues(
ws.editorState().values().withMaxTitleLength("110"));
ws.validateButton.fire();
assertTrue(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
"Value in high warn band must produce a field finding");
boolean hasErrorForField = ws.lastValidationResult().fieldFindings().stream()
.anyMatch(f -> "max.title.length".equals(f.fieldKey())
&& f.severity() == GuiMessageSeverity.ERROR);
assertFalse(hasErrorForField,
"Value in high warn band must not produce an ERROR for this field");
});
}
/**
* Smoke test: the default template value of 60 produces no finding for the title-length field.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void defaultMaxTitleLength_producesNoFieldFinding() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws =
new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
assertNotNull(ws.lastValidationResult(),
"lastValidationResult must not be null after 'Neu'");
assertFalse(ws.lastValidationResult().hasFieldFindingFor("max.title.length"),
"Default value 60 must not produce a field finding");
});
}
// =========================================================================
// Helpers
// =========================================================================
@@ -458,6 +613,7 @@ class GuiEditorValidationSmokeTest {
+ "max.retries.transient=3\n"
+ "max.pages=10\n"
+ "max.text.characters=500\n"
+ "max.title.length=60\n"
+ "prompt.template.file=./config/prompt.txt\n";
Files.writeString(path, content, StandardCharsets.UTF_8);
}
@@ -0,0 +1,202 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import javafx.application.Platform;
import javafx.scene.control.Tab;
/**
* Monocle-basierte Headless-Smoke-Tests für {@link GuiHistoryTab}.
* <p>
* Geprüfte Szenarien:
* <ul>
* <li>Tab wird mit Titel Verlauf" erstellt.</li>
* <li>Tab ist nicht schließbar.</li>
* <li>Ohne geladene Konfiguration bleibt die Übersicht leer (null-configPath).</li>
* <li>Mit leerem Übersichts-Port bleibt die Tabelle leer.</li>
* </ul>
*/
class GuiHistoryTabSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform muss innerhalb des Timeouts starten");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Gemeinsame Platform kein Platform.exit().
}
// =========================================================================
// Stubs
// =========================================================================
private static GuiHistoryOverviewPort emptyOverviewPort() {
return (configFilePath, query) ->
new HistoryOverviewResult(List.of(), false);
}
private static GuiHistoryDetailsPort emptyDetailsPort() {
return (configFilePath, fingerprint) -> Optional.empty();
}
private static GuiHistoryResetDocumentStatusPort noOpResetPort() {
return (configFilePath, fingerprint) -> { /* no-op */ };
}
private static GuiDeleteDocumentHistoryPort noOpDeletePort() {
return (configFilePath, fingerprint) -> { /* no-op */ };
}
private static GuiHistoryTab buildTab(Path configPath) {
return new GuiHistoryTab(
emptyOverviewPort(),
emptyDetailsPort(),
noOpResetPort(),
noOpDeletePort(),
() -> false,
() -> configPath);
}
// =========================================================================
// Tests
// =========================================================================
@Test
void tab_shouldHaveTitleVerlauf() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<Tab> tabRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
GuiHistoryTab historyTab = buildTab(null);
tabRef.set(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
assertEquals("Verlauf", tabRef.get().getText(), "Tab-Titel muss 'Verlauf' sein");
}
@Test
void tab_shouldNotBeClosable() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean closableRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
GuiHistoryTab historyTab = buildTab(null);
closableRef.set(historyTab.tab().isClosable());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(closableRef.get(), "Tab darf nicht schließbar sein");
}
@Test
void construction_withNullConfigPath_doesNotThrow() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
// Konstruktion mit null-configPath-Supplier muss möglich sein
GuiHistoryTab historyTab = buildTab(null);
assertNotNull(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
}
@Test
void construction_withConfigPath_doesNotThrow() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
Path dummyPath = Paths.get("config/application.properties");
GuiHistoryTab historyTab = buildTab(dummyPath);
assertNotNull(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
}
}
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
@@ -335,7 +336,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -360,6 +362,79 @@ class GuiMessageAreaSmokeTest {
});
}
// =========================================================================
// Scenario: absent API key origin label hidden, no duplicate in message area
// =========================================================================
/**
* Smoke test: when no API key is configured and no environment variable provides one, the
* api-key origin label below the Claude API-key field must be hidden (not visible).
* <p>
* The field-error label (registered in {@code fieldErrorLabels}) already shows the warning
* below the input field; duplicating the same text in the origin label would cause the same
* message to appear twice in close visual proximity. The origin label is therefore hidden
* for the absent-key case.
*/
@Test
void apiKeyAbsent_originLabelHidden() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
// Standard template has active provider = Claude but no API key value.
Label originLabel = ws.apiKeyOriginLabels.get(AiProviderFamily.CLAUDE);
assertNotNull(originLabel,
"An api-key origin label must be registered for AiProviderFamily.CLAUDE");
assertFalse(originLabel.isVisible(),
"Claude api-key origin label must be HIDDEN when no API key is configured "
+ "— the field-error label already shows the warning, so the origin label "
+ "must not repeat it");
});
}
/**
* Smoke test: when no API key is configured, the field-error label registered for
* {@code ai.provider.claude.apiKey} must be visible and carry a non-blank warning text.
* <p>
* This is the single intended location for the missing-key warning below the input field.
*/
@Test
void apiKeyAbsent_fieldErrorLabelVisible() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
Label errorLabel = ws.fieldErrorLabels.get("ai.provider.claude.apiKey");
assertNotNull(errorLabel,
"A field-error label must be registered for 'ai.provider.claude.apiKey'");
assertTrue(errorLabel.isVisible(),
"api-key field-error label must be visible when no API key is configured");
assertFalse(errorLabel.getText().isBlank(),
"api-key field-error label must carry a non-blank warning text");
});
}
/**
* Smoke test: when no API key is configured, the text of the missing-key warning must appear
* exactly once in {@code pendingMessages}. Having the same text twice would mean two
* validation sources independently write the same finding to the central message area.
*/
@Test
void apiKeyAbsent_noDuplicateMessageInPendingMessages() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
// Collect all message texts that contain the canonical "Kein API-Schlüssel" fragment.
long count = ws.pendingMessages.stream()
.filter(m -> m.text().contains("API-Schlüssel") || m.text().contains("API-Key"))
.count();
assertTrue(count <= 1,
"The missing-API-key finding must appear at most once in pendingMessages, "
+ "but found " + count + " occurrences");
});
}
// =========================================================================
// Scenario: INFO-coloured prefix in the message area (model-catalogue success)
// =========================================================================
@@ -404,7 +479,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -491,7 +567,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -609,6 +686,145 @@ class GuiMessageAreaSmokeTest {
});
}
// =========================================================================
// Scenario: message area is cleared when a new configuration is applied
// =========================================================================
/**
* Smoke test: after "Neu" is triggered on a workspace that already has a message, the
* central message area must no longer contain that pre-existing message entry.
* <p>
* This verifies that {@code applyEditorState} clears {@code pendingMessages} so that
* messages from a previous configuration do not bleed into the freshly loaded one.
*/
@Test
void newConfiguration_clearsPreviousMessages() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
// Seed a message that must not survive a "Neu" action.
ws.pendingMessages.add(
GuiMessageEntry.of(GuiMessageSeverity.ERROR, "Alter Fehler", "Test"));
ws.refreshMessagesArea();
assertFalse(ws.pendingMessages.isEmpty(),
"Pre-condition: pending messages must not be empty before 'Neu'");
ws.requestNewConfiguration();
assertFalse(
ws.pendingMessages.stream()
.anyMatch(m -> "Alter Fehler".equals(m.text())),
"The pre-existing message must have been removed after 'Neu'");
});
}
// =========================================================================
// Scenario: "Meldungen leeren" button clears the message area
// =========================================================================
/**
* Smoke test: invoking {@code clearMessages()} removes all entries from
* {@code pendingMessages} and causes the visible message area to show the
* placeholder instead of previous entries.
*/
@Test
void clearMessages_removesAllEntries() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
// Seed some messages.
ws.pendingMessages.add(
GuiMessageEntry.of(GuiMessageSeverity.INFO, "Info-Meldung", "Test"));
ws.pendingMessages.add(
GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Warnung", "Test"));
ws.refreshMessagesArea();
assertFalse(ws.pendingMessages.isEmpty(),
"Pre-condition: pending messages must not be empty before clearing");
ws.clearMessages();
assertTrue(ws.pendingMessages.isEmpty(),
"pendingMessages must be empty after clearMessages()");
assertTrue(ws.messagesAreaBox.getChildren().isEmpty()
|| ws.messagesAreaBox.getChildren().stream()
.noneMatch(n -> n instanceof javafx.scene.text.TextFlow tf
&& tf.getChildren().stream()
.anyMatch(c -> c instanceof javafx.scene.text.Text t
&& (t.getText().contains("Info-Meldung")
|| t.getText().contains("Warnung")))),
"messagesAreaBox must not contain the cleared message texts after clearMessages()");
});
}
// =========================================================================
// Scenario: "Validieren" clears previous messages before showing results
// =========================================================================
/**
* Smoke test: invoking {@code runValidationAction()} via the validate button removes
* pre-existing messages so that results from a previous action do not accumulate.
*/
@Test
void validationAction_clearsPreviousMessages() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
// Seed a stale message that must not survive the validation action.
ws.pendingMessages.add(
GuiMessageEntry.of(GuiMessageSeverity.ERROR, "Alter Befund", "Test"));
assertFalse(ws.pendingMessages.isEmpty(),
"Pre-condition: pending messages must not be empty before Validieren");
ws.validateButton.fire();
assertFalse(
ws.pendingMessages.stream()
.anyMatch(m -> "Alter Befund".equals(m.text())),
"The stale message must have been removed after Validieren");
});
}
// =========================================================================
// Scenario: "Technische Tests ausführen" clears previous messages before starting
// =========================================================================
/**
* Smoke test: invoking the technical-tests action removes pre-existing messages so that
* results from a previous action do not accumulate.
* <p>
* The clear happens synchronously on the FX thread before the background worker starts.
* The thread factory is replaced with a no-op so no background thread is actually
* started, which prevents native dialog calls that are not supported under Monocle.
*/
@Test
void technicalTestsAction_clearsPreviousMessages() throws Exception {
runOnFx(() -> {
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
ws.requestNewConfiguration();
// Replace thread factory with a no-op so no background work runs in Monocle.
ws.technicalTestCoordinator.testThreadFactory = task -> new Thread(() -> { }) {
@Override
public void start() {
// Do not start we only verify the synchronous clear, not the test result.
}
};
ws.pendingMessages.add(
GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Alte Warnung", "Test"));
assertFalse(ws.pendingMessages.isEmpty(),
"Pre-condition: pending messages must not be empty before technical tests");
ws.technicalTestsButton.fire();
assertFalse(
ws.pendingMessages.stream()
.anyMatch(m -> "Alte Warnung".equals(m.text())),
"The stale message must have been removed after Technische Tests ausführen");
});
}
// =========================================================================
// Scenario: ai.provider.active field-error label is registered and shown
// =========================================================================
@@ -675,7 +891,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -529,7 +529,8 @@ class GuiModelCatalogSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -0,0 +1,371 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
import javafx.application.Platform;
import javafx.scene.control.Tab;
/**
* Monocle-basierte Headless-Smoke-Tests für {@link GuiPromptEditorTab}.
* <p>
* Geprüfte Szenarien:
* <ul>
* <li>Tab wird korrekt mit Titel Prompt" erstellt.</li>
* <li>Dirty-State ist nach Konstruktion {@code false}.</li>
* <li>Nach synchronem Laden mit Erfolg: Dirty-State bleibt {@code false},
* Tab-Titel enthält keinen Asterisk.</li>
* <li>Nach synchronem Laden mit FILE_NOT_FOUND: Dirty-State bleibt {@code false}.</li>
* <li>Nach synchronem Speichern mit Erfolg: Dirty-State zurückgesetzt.</li>
* <li>Nach {@code resetToDefault}: Textfeld enthält Default-Inhalt (nicht leer),
* Dirty-State ist {@code true} (Abweichung von geladener Baseline).</li>
* </ul>
*/
class GuiPromptEditorTabSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform muss innerhalb des Timeouts starten");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Gemeinsame Platform kein Platform.exit().
}
// =========================================================================
// Hilfsklassen
// =========================================================================
/** Synchroner Stub-Port: gibt vorbereitete Ergebnisse sofort zurück. */
private static class SyncPromptEditorPort implements GuiPromptEditorPort {
PromptLoadingResult loadResult = new PromptLoadingSuccess(
new PromptIdentifier("test-prompt.txt"), "Stub-Prompt-Inhalt");
PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/test-prompt.txt");
@Override
public PromptLoadingResult loadCurrentPrompt() {
return loadResult;
}
@Override
public PromptSaveResult save(String content) {
return saveResult;
}
@Override
public CorrectionOutcome createDefaultPromptIfMissing(
CorrectionSuggestion.CreatePromptFile suggestion) {
return new CorrectionOutcome.Applied(suggestion, "Stub-Prompt-Datei angelegt.");
}
}
/**
* Erstellt einen {@link GuiPromptEditorTab} mit synchronen Stubs:
* threadFactory führt den Runnable inline aus (vor worker.start()),
* fxDispatcher gibt den UI-Update-Runnable direkt weiter (kein Platform.runLater).
* Damit sind alle Operationen aus Testsicht vollständig synchron.
*/
private static GuiPromptEditorTab buildSyncTab(SyncPromptEditorPort port) {
GuiPromptEditorTab tab = new GuiPromptEditorTab(port, "/stub/test-prompt.txt", 60);
// Runnable wird inline ausgeführt; der zurückgegebene Thread startet leer (kein-op).
tab.threadFactory = runnable -> {
runnable.run(); // Synchron ausführen, inkl. fxDispatcher-Aufruf
return new Thread(); // Dummy-Thread; worker.start() beendet sofort
};
// UI-Updates synchron im selben Thread
tab.fxDispatcher = Runnable::run;
return tab;
}
// =========================================================================
// Tests
// =========================================================================
@Test
void tab_shouldBeCreatedWithTitlePrompt() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<Tab> tabRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
tabRef.set(editorTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
assertEquals("Prompt", tabRef.get().getText(), "Tab-Titel muss 'Prompt' sein");
}
@Test
void dirtyState_shouldBeFalse_afterConstruction() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
dirtyRef.set(editorTab.hasDirtyContent());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(), "Dirty-State muss nach Konstruktion false sein");
}
@Test
void dirtyState_shouldBeFalse_afterSuccessfulLoad() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
// Laden synchron auslösen (fxDispatcher = Runnable::run)
editorTab.loadPromptAsync();
dirtyRef.set(editorTab.hasDirtyContent());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(), "Dirty-State muss nach erfolgreichem Laden false sein");
}
@Test
void tabTitle_shouldNotContainAsterisk_afterSuccessfulLoad() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<String> titleRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
editorTab.loadPromptAsync();
titleRef.set(editorTab.tab().getText());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(titleRef.get().contains("*"),
"Tab-Titel darf nach erfolgreichem Laden keinen Asterisk enthalten");
}
@Test
void dirtyState_shouldBeFalse_whenLoadReturnsFileNotFound() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
port.loadResult = new PromptLoadingFailure("FILE_NOT_FOUND", "Datei nicht gefunden");
GuiPromptEditorTab editorTab = buildSyncTab(port);
editorTab.loadPromptAsync();
dirtyRef.set(editorTab.hasDirtyContent());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(), "Dirty-State muss false sein wenn Datei nicht gefunden wurde");
}
@Test
void notifyConfigurationChanged_shouldResetDirtyStateAndTitle() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
AtomicReference<String> titleRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
// Laden und anschliessend Inhalt aendern, um Dirty-State zu erzeugen
editorTab.loadPromptAsync();
editorTab.resetToDefault();
// Vorbedingung: Dirty-State muss aktiv sein
assertTrue(editorTab.hasDirtyContent(),
"Vorbedingung: Dirty-State muss nach resetToDefault aktiv sein");
// Konfiguration wechseln Dirty-State und Titel sollen zurueckgesetzt werden
editorTab.notifyConfigurationChanged(new SyncPromptEditorPort(), "/new/prompt.txt", 80);
dirtyRef.set(editorTab.hasDirtyContent());
titleRef.set(editorTab.tab().getText());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(),
"Dirty-State muss nach notifyConfigurationChanged false sein");
assertFalse(titleRef.get().contains("*"),
"Tab-Titel darf nach notifyConfigurationChanged keinen Asterisk enthalten; Titel war: "
+ titleRef.get());
}
@Test
void tabTitle_shouldContainAsterisk_afterEditWithLoadedBaseline() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<String> titleRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
editorTab.loadPromptAsync();
// Direkte TextArea-Manipulation simuliert Benutzer-Eingabe
// Über Reflection auf das private textArea-Feld zugreifen ist unerwünscht.
// Stattdessen: resetToDefault() setzt einen anderen Inhalt als den geladenen,
// was den Dirty-State auslöst.
editorTab.resetToDefault();
titleRef.set(editorTab.tab().getText());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
// Nach resetToDefault() wird der Default-Inhalt gesetzt.
// Falls dieser vom geladenen Inhalt abweicht, entsteht ein Dirty-State.
// Da Stub-Inhalt != Default-Template, muss Asterisk vorhanden sein.
assertTrue(titleRef.get().contains("*"),
"Tab-Titel muss nach Bearbeitung (resetToDefault) einen Asterisk enthalten; Titel war: "
+ titleRef.get());
}
@Test
void discardChanges_shouldResetDirtyStateAndTitle() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
AtomicReference<String> titleRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
editorTab.loadPromptAsync();
editorTab.resetToDefault();
// Vorbedingung: Dirty-State muss aktiv sein
assertTrue(editorTab.hasDirtyContent(),
"Vorbedingung: Dirty-State muss nach resetToDefault aktiv sein");
// Verwerfen simulieren
editorTab.discardChanges();
dirtyRef.set(editorTab.hasDirtyContent());
titleRef.set(editorTab.tab().getText());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(),
"Dirty-State muss nach discardChanges false sein");
assertFalse(titleRef.get().contains("*"),
"Tab-Titel darf nach discardChanges keinen Asterisk enthalten; Titel war: "
+ titleRef.get());
}
}
@@ -0,0 +1,341 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.application.Platform;
/**
* Tests für die Statuszeilen-Komponente {@link GuiStatusBar}.
* <p>
* Überprüft die Versionsanzeige, den Provider-Text und den Konfigurationspfad-Text
* in den verschiedenen Zuständen (ohne und mit geladener Konfiguration).
* <p>
* Die Tests laufen unter Monocle (Headless-JavaFX), da {@link GuiStatusBar} JavaFX-Controls erzeugt.
*/
class GuiStatusBarTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
/**
* Initialisiert die JavaFX-Plattform einmalig für alle Tests dieser Klasse.
*
* @throws InterruptedException falls der Thread beim Warten unterbrochen wird
*/
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch startLatch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
startLatch.countDown();
});
} catch (IllegalStateException alreadyInitialized) {
// JavaFX wurde bereits durch einen anderen Test gestartet
PLATFORM_STARTED.set(true);
startLatch.countDown();
}
assertTrue(
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX-Plattform muss innerhalb des Timeouts starten");
}
/** Plattform bleibt für nachfolgende Tests am Leben. */
@AfterAll
static void tearDownJavaFxPlatform() {
// Absichtlich kein Platform.exit() damit andere Smoke-Tests weiterhin die Plattform nutzen können.
}
// =========================================================================
// Versionsanzeige
// =========================================================================
/**
* Prüft, dass die Versionsanzeige das korrekte Präfix und die übergebene Version enthält.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void versionLabel_zeigtVersionMitPraefix() throws Exception {
AtomicReference<String> versionText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("3.0.42");
versionText.set(bar.versionText());
});
assertEquals("V3.0.42", versionText.get(),
"Die Versionsanzeige muss das Präfix 'V' gefolgt von der Versionsnummer enthalten");
}
/**
* Prüft, dass ein {@code null}-Wert für die Version als {@code "dev"} angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void versionLabel_mitNullFaellzurueckAufDev() throws Exception {
AtomicReference<String> versionText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar(null);
versionText.set(bar.versionText());
});
assertEquals("Vdev", versionText.get(),
"Ein null-Wert muss als Fallback 'dev' angezeigt werden");
}
/**
* Prüft, dass ein leerer String für die Version als {@code "dev"} angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void versionLabel_mitLeeremStringFaellzurueckAufDev() throws Exception {
AtomicReference<String> versionText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar(" ");
versionText.set(bar.versionText());
});
assertEquals("Vdev", versionText.get(),
"Ein leerer String muss als Fallback 'dev' angezeigt werden");
}
// =========================================================================
// Standardzustand ohne geladene Konfiguration
// =========================================================================
/**
* Prüft, dass Mitte und Rechts den Text Kein Profil geladen" zeigen, wenn keine
* Konfiguration geladen ist.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void ohneKonfiguration_zeigtKeinProfilGeladen() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
AtomicReference<String> configPathText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
providerText.set(bar.providerText());
configPathText.set(bar.configPathText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Provider-Text erscheinen");
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
}
/**
* Prüft, dass {@link GuiStatusBar#clearConfiguration()} Mitte und Rechts zurücksetzt.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void clearConfiguration_setztMitteUndRechtsZurueck() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
AtomicReference<String> configPathText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
// Zustand mit Konfiguration setzen, dann löschen
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
bar.applyEditorState(state);
bar.clearConfiguration();
providerText.set(bar.providerText());
configPathText.set(bar.configPathText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Nach clearConfiguration() muss 'Kein Profil geladen' als Provider-Text erscheinen");
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
"Nach clearConfiguration() muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
}
// =========================================================================
// Zustand nach Laden einer Konfiguration
// =========================================================================
/**
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Provider-Text
* mit Modell angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitClaudeUndModell_zeigtKorrektesFormat() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
bar.applyEditorState(state);
providerText.set(bar.providerText());
});
assertEquals("Provider: Claude · claude-opus-4-7", providerText.get(),
"Der Provider-Text muss das Format 'Provider: <Name> · <Modell>' haben");
}
/**
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Konfigurationspfad
* angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitKonfigurationspfad_zeigtKonfiguationspfad() throws Exception {
AtomicReference<String> configPathText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
bar.applyEditorState(state);
configPathText.set(bar.configPathText());
});
assertTrue(configPathText.get().contains("application.properties"),
"Der Konfigurationspfad muss den Dateinamen enthalten");
}
/**
* Prüft, dass ein OpenAI-kompatibler Provider korrekt angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitOpenAiUndModell_zeigtKorrektesFormat() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.OPENAI_COMPATIBLE, "gpt-4o");
bar.applyEditorState(state);
providerText.set(bar.providerText());
});
assertEquals("Provider: OpenAI-kompatibel · gpt-4o", providerText.get(),
"Der Provider-Text muss für OpenAI-kompatibel den deutschen Anzeigenamen verwenden");
}
/**
* Prüft, dass beim Übergeben eines {@code null}-Zustands kein Absturz erfolgt und der
* Text Kein Profil geladen" erscheint.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitNull_keinAbsturz() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
bar.applyEditorState(null);
providerText.set(bar.providerText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Ein null-Zustand darf keinen Absturz verursachen");
}
/**
* Prüft, dass ohne geladenen Dateisnapshot Kein Profil geladen" angezeigt wird,
* auch wenn Konfigurationswerte vorhanden sind.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_ohneSnapshot_zeigtKeinProfilGeladen() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
// Standard-Template hat keinen Snapshot
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
bar.applyEditorState(state);
providerText.set(bar.providerText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Ohne geladenen Dateisnapshot muss 'Kein Profil geladen' erscheinen");
}
/**
* Prüft, dass der Wurzelknoten der Statuszeile nicht null ist.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void root_istNichtNull() throws Exception {
AtomicBoolean rootNotNull = new AtomicBoolean(false);
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
rootNotNull.set(bar.root() != null);
});
assertTrue(rootNotNull.get(), "Der Wurzelknoten der Statuszeile darf nicht null sein");
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
/**
* Führt eine Aktion synchron auf dem JavaFX Application Thread aus und wartet auf Abschluss.
*
* @param action die auszuführende Aktion
* @throws Exception falls die Aktion einen Fehler wirft oder das Timeout überschritten wird
*/
private static void runOnFxThread(Runnable action) throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try {
action.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX-Thread-Task muss innerhalb des Timeouts abgeschlossen werden");
if (error.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", error.get());
}
}
/**
* Erstellt einen Editor-Zustand mit geladenem Dateisnapshot für den angegebenen
* Konfigurationspfad, Provider und Modell.
*
* @param configPath relativer Konfigurationsdateipfad
* @param family aktive Provider-Familie
* @param model Modellbezeichner
* @return ein Editor-Zustand mit Snapshot
*/
private static GuiConfigurationEditorState buildStateWithConfiguration(
String configPath, AiProviderFamily family, String model) {
GuiConfigurationEditorState template = GuiConfigurationTemplateFactory.createStandardTemplate();
// Provider und Modell setzen
GuiProviderConfigurationState providerState = new GuiProviderConfigurationState(
"https://api.example.com", model, "30",
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState.unresolved());
GuiConfigurationValues values = template.values()
.withActiveProviderFamily(family.getIdentifier())
.withProviderConfiguration(family, providerState);
// Snapshot anlegen
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
Path.of(configPath), new Properties());
return new GuiConfigurationEditorState(
Optional.of(snapshot), values, values, Optional.empty());
}
}
@@ -39,7 +39,7 @@ import javafx.scene.control.Button;
* {@code technical-tests-button}.</li>
* <li>Triggering the coordinator synchronously populates {@code pendingMessages}
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li>
* <li>A second trigger appends a fresh batch of test entries (accumulation semantics).</li>
* <li>A second trigger replaces the previous batch of test entries.</li>
* <li>The post-result callback is invoked after the result is applied.</li>
* </ul>
* <p>
@@ -138,12 +138,12 @@ class GuiTechnicalTestCoordinatorSmokeTest {
/**
* Smoke test: after one trigger, the number of entries tagged SOURCE_TAG equals
* 11 (one per checkpoint) plus 1 summary entry = 12.
* 12 (one per checkpoint) plus 1 summary entry = 13.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void trigger_producesElevenCheckpointEntriesPlusSummary() throws Exception {
void trigger_producesTwelveCheckpointEntriesPlusSummary() throws Exception {
runOnFx(() -> {
List<GuiMessageEntry> messages = new ArrayList<>();
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
@@ -155,25 +155,26 @@ class GuiTechnicalTestCoordinatorSmokeTest {
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
.count();
// 11 checkpoint entries + 1 summary entry = 12
assertEquals(12, taggedCount,
"Expected 11 checkpoint entries + 1 summary entry = 12 tagged messages");
// 12 checkpoint entries + 1 summary entry = 13
assertEquals(13, taggedCount,
"Expected 12 checkpoint entries + 1 summary entry = 13 tagged messages");
});
}
// =========================================================================
// Scenario: accumulation semantics second trigger appends fresh entries
// Scenario: replace semantics second trigger replaces the previous batch
// =========================================================================
/**
* Smoke test: triggering the coordinator twice accumulates both runs; the
* second trigger appends a fresh batch of SOURCE_TAG entries without
* removing the first batch.
* Smoke test: triggering the coordinator twice replaces the previous batch;
* the second trigger clears the shared message list before applying its own
* SOURCE_TAG entries, so the count after the second run equals the count
* after the first run.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
void trigger_twice_accumulatesTestEntries() throws Exception {
void trigger_twice_replacesTestEntries() throws Exception {
runOnFx(() -> {
List<GuiMessageEntry> messages = new ArrayList<>();
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
@@ -190,8 +191,8 @@ class GuiTechnicalTestCoordinatorSmokeTest {
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
.count();
assertEquals(countAfterFirst * 2, countAfterSecond,
"Second trigger must append a fresh batch, doubling the SOURCE_TAG entries");
assertEquals(countAfterFirst, countAfterSecond,
"Second trigger must clear and replace the previous SOURCE_TAG batch");
});
}
@@ -246,7 +247,7 @@ class GuiTechnicalTestCoordinatorSmokeTest {
new EditorValidationInput(
"claude",
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "500",
"3", "10", "500", "60",
"https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30",
@@ -255,12 +256,14 @@ class GuiTechnicalTestCoordinatorSmokeTest {
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
new EditorConfigurationValidator(),
noOpPathCheckPort(),
noOpProviderService());
noOpProviderService(),
() -> java.util.Optional.empty());
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
orchestrator,
currentInput::get, // always reads the current reference
() -> "",
() -> "",
messages,
report -> { });
@@ -282,7 +285,7 @@ class GuiTechnicalTestCoordinatorSmokeTest {
currentInput.set(new EditorValidationInput(
"", // empty active provider validation error in block 1
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "500",
"3", "10", "500", "60",
"https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30",
@@ -364,12 +367,13 @@ class GuiTechnicalTestCoordinatorSmokeTest {
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
new EditorConfigurationValidator(),
noOpPathCheckPort(),
noOpProviderService());
noOpProviderService(),
() -> java.util.Optional.empty());
EditorValidationInput blankInput = new EditorValidationInput(
"claude",
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
"3", "10", "2000",
"3", "10", "2000", "60",
"https://api.anthropic.com", "claude-3-sonnet", "30",
EffectiveApiKeyDescriptor.absent(), "",
"https://api.openai.com", "gpt-4", "30",
@@ -379,6 +383,7 @@ class GuiTechnicalTestCoordinatorSmokeTest {
orchestrator,
() -> blankInput,
() -> "",
() -> "",
messages,
postResultCallback);
@@ -0,0 +1,205 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Unit-Tests für {@link GuiTooltipTexts}.
* <p>
* Prüft, dass alle öffentlichen Tooltip-Konstanten vorhanden sind, nicht leer sind
* und den exakten Texten gemäß Spezifikation entsprechen.
*/
class GuiTooltipTextsTest {
// -------------------------------------------------------------------------
// Vollständigkeit und Nicht-Leerheit aller Konstanten
// -------------------------------------------------------------------------
@Test
void alleKonstantenSindNichtNullUndNichtLeer() {
List<String> fehler = new ArrayList<>();
for (Field field : GuiTooltipTexts.class.getDeclaredFields()) {
if (!Modifier.isPublic(field.getModifiers())
|| !Modifier.isStatic(field.getModifiers())
|| !Modifier.isFinal(field.getModifiers())) {
continue;
}
try {
Object value = field.get(null);
if (value == null) {
fehler.add(field.getName() + " ist null");
} else if (value instanceof String s && s.isBlank()) {
fehler.add(field.getName() + " ist leer");
}
} catch (IllegalAccessException e) {
fehler.add(field.getName() + " nicht zugreifbar: " + e.getMessage());
}
}
if (!fehler.isEmpty()) {
org.junit.jupiter.api.Assertions.fail(
"Fehlerhafte Tooltip-Konstanten: " + String.join(", ", fehler));
}
}
// -------------------------------------------------------------------------
// Toolbar-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void toolbar_neu_entsprichtSpezifikation() {
assertNotNull(GuiTooltipTexts.TOOLBAR_NEU);
assertFalse(GuiTooltipTexts.TOOLBAR_NEU.isBlank());
org.junit.jupiter.api.Assertions.assertEquals(
"Neue Konfiguration erstellen.",
GuiTooltipTexts.TOOLBAR_NEU);
}
@Test
void toolbar_oeffnen_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Bestehende Konfigurationsdatei (.properties) öffnen.",
GuiTooltipTexts.TOOLBAR_OEFFNEN);
}
@Test
void toolbar_speichern_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Aktuelle Konfiguration speichern.",
GuiTooltipTexts.TOOLBAR_SPEICHERN);
}
@Test
void toolbar_speichernUnter_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Konfiguration unter neuem Dateipfad speichern.",
GuiTooltipTexts.TOOLBAR_SPEICHERN_UNTER);
}
@Test
void toolbar_validieren_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.",
GuiTooltipTexts.TOOLBAR_VALIDIEREN);
}
@Test
void toolbar_technischeTests_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.",
GuiTooltipTexts.TOOLBAR_TECHNISCHE_TESTS);
}
// -------------------------------------------------------------------------
// Pfade-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void pfade_quellordner_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.",
GuiTooltipTexts.PFADE_QUELLORDNER);
}
@Test
void pfade_zielordner_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Ordner für die umbenannten Kopien.",
GuiTooltipTexts.PFADE_ZIELORDNER);
}
@Test
void pfade_sqlite_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.",
GuiTooltipTexts.PFADE_SQLITE);
}
@Test
void pfade_prompt_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Externe Textdatei mit den KI-Anweisungen.",
GuiTooltipTexts.PFADE_PROMPT);
}
// -------------------------------------------------------------------------
// Provider-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void provider_combobox_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Der KI-Dienst, der die Dateinamen generiert.",
GuiTooltipTexts.PROVIDER_COMBOBOX);
}
@Test
void provider_modell_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Das konkrete Sprachmodell des gewählten Providers.",
GuiTooltipTexts.PROVIDER_MODELL);
}
// -------------------------------------------------------------------------
// Verarbeitungslimits-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void limits_maxTextCharacters_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.",
GuiTooltipTexts.LIMITS_MAX_TEXT_CHARACTERS);
}
@Test
void limits_maxPages_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Maximale Seitenzahl, die aus einem PDF gelesen wird.",
GuiTooltipTexts.LIMITS_MAX_PAGES);
}
@Test
void limits_maxTitleLength_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10120.",
GuiTooltipTexts.LIMITS_MAX_TITLE_LENGTH);
}
// -------------------------------------------------------------------------
// Verarbeitungslauf-Tab-Tooltips exakter Text gemäß Spezifikation
// -------------------------------------------------------------------------
@Test
void dateiname_uebernehmen_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.",
GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
}
@Test
void dateiname_zuruecksetzen_entsprichtSpezifikation() {
org.junit.jupiter.api.Assertions.assertEquals(
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.",
GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
}
// -------------------------------------------------------------------------
// Nicht instanziierbar
// -------------------------------------------------------------------------
@Test
void konstruktorWirftException() throws Exception {
Constructor<GuiTooltipTexts> ctor = GuiTooltipTexts.class.getDeclaredConstructor();
ctor.setAccessible(true);
assertThrows(java.lang.reflect.InvocationTargetException.class, ctor::newInstance,
"Der private Konstruktor muss UnsupportedOperationException werfen");
}
}
@@ -806,7 +806,8 @@ class GuiUnsavedChangesGuardSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -851,7 +852,8 @@ class GuiUnsavedChangesGuardSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -874,6 +876,7 @@ class GuiUnsavedChangesGuardSmokeTest {
v.maxRetriesTransient(),
v.maxPages(),
v.maxTextCharacters(),
v.maxTitleLength(),
v.logAiSensitive(),
v.activeProviderFamily(),
v.providerConfigurations());
@@ -244,13 +244,13 @@ class GuiValidateActionSmokeTest {
}
// =========================================================================
// Scenario: clicking twice message appears exactly once (replace semantics)
// Scenario: clicking twice exactly one message present (replace semantics)
// =========================================================================
/**
* Smoke test: clicking "Validieren" twice must leave two action-confirmation
* INFO messages in the message list (accumulation semantics each click appends
* a fresh snapshot of findings).
* Smoke test: clicking "Validieren" twice must leave exactly one action-confirmation
* INFO message in the message list. Each click clears the previous messages before
* adding the new result, so messages from an earlier click do not accumulate.
*
* @throws Exception if the FX thread task fails or times out
*/
@@ -267,8 +267,9 @@ class GuiValidateActionSmokeTest {
&& ACTION_SOURCE.equals(m.source().get()))
.filter(m -> m.text().startsWith("Aktion Validieren wurde ausgeführt."))
.count();
assertEquals(2, confirmationCount,
"After two clicks two action-confirmation INFO messages must be present");
assertEquals(1, confirmationCount,
"After two clicks exactly one action-confirmation INFO message must be present"
+ " (second click replaces messages from the first click)");
});
}
@@ -322,7 +323,8 @@ class GuiValidateActionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
noOpApiKeyResolutionPort())),
noOpApiKeyResolutionPort()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -389,7 +391,8 @@ class GuiValidateActionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
noOpApiKeyResolutionPort())),
noOpApiKeyResolutionPort()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }

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