51 Commits

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

Fix: beide Module explizit in <addModules> aufgenommen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Alle 323 Tests bestanden.

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

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

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

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-27 19:04:02 +02:00
131 changed files with 11980 additions and 1279 deletions
+2
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
+19 -24
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:
@@ -138,30 +147,9 @@ V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben.
Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technische Tests) ist abgeschlossen.
**V2.9 ist abgeschlossen.** Der Tab „Verarbeitungslauf" wurde erweitert um:
**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.
- **Integrierte PDF-Vorschau** (`PdfPreviewPane`) mit Lazy-Rendering, In-Memory-Cache und
Seitennavigation. Das Rendering erfolgt direkt über PDFBox
(`PDFRenderer.renderImageWithDPI` + `SwingFXUtils.toFXImage`); eine externe PDFViewFX-Abhängigkeit
wird nicht mehr verwendet.
- **Editierbarer Dateiname-Bereich** (`FileNameEditorPane`) mit Live-Validierung, Dirty-State-Dialog
bei Zeilen-/Tabwechsel, Schließen und Laufstart sowie atomarer Dateisystem- und DB-Transaktion
inkl. Rollback und Fingerprint-basierter Konfliktauflösung.
Neue Architekturkomponenten in V2.9:
- Outbound-Port `TargetFileRenamePort` (`pdf-umbenenner-application`)
- Application-Use-Case `ManualFileRenameUseCase` / `DefaultManualFileRenameUseCase`
- Adapter-Out `FilesystemTargetFileRenameAdapter` (`pdf-umbenenner-adapter-out`)
- GUI-interner Port `GuiManualFileRenamePort` (`pdf-umbenenner-adapter-in-gui`)
Weitere Verhaltensänderungen:
- Die GUI startet **maximiert** (Vollbild); `stage.setMaximized(true)` in `PdfUmbenennerGuiApplication`.
- Beim Start wird die **zuletzt geladene Konfigurationsdatei** automatisch geladen
(gespeichert in `java.util.prefs.Preferences` unter Schlüssel `lastConfigPath`,
umgesetzt in `GuiConfigurationEditorWorkspace.autoLoadLastConfiguration()`).
Existiert die Datei nicht mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
Verhaltensänderungen seit V2.9: Die GUI startet maximiert, und die zuletzt geladene Konfigurationsdatei wird beim Start automatisch wieder geladen; existiert sie nicht mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert.
@@ -252,6 +240,13 @@ Bestehende Kommentare mit solchen Bezeichnern, die durch eigene Änderungen ber
- Keine stillen Änderungen am bestehenden headless Batch-Betrieb
- GUI-Code darf den headless Pfad nicht unnötig früh initialisieren
## Commit und Push nach jeder Implementierung
Nach jeder Implementierung oder Dateiänderung wird ein Commit auf `main` erstellt und gepusht:
1. Geänderte Dateien stagen und committen
2. `git push origin main` ausführen
3. Schlägt der Push mit einem AUTH-Fehler fehl: 1 Sekunde warten, dann genau **einen** weiteren Versuch unternehmen
4. Schlägt auch der zweite Versuch fehl: Fehler benennen, keinen weiteren automatischen Retry
## Definition of Done pro Arbeitspaket
Ein Arbeitspaket ist erst fertig, wenn:
- der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist
Vendored
+184
View File
@@ -0,0 +1,184 @@
// Jenkins-Pipeline für den PDF KI Renamer
// Läuft auf einem Linux-Container (Synology NAS).
// Der MSI-Build ist Windows-only (jpackage + WiX Toolset 3.x). Jenkins läuft im
// Linux-Container auf Synology NAS und kann kein MSI erzeugen. Der MSI-Build
// wird bewusst manuell auf der Windows-Entwicklungsmaschine ausgeführt:
// .\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests
pipeline {
agent any
options {
disableConcurrentBuilds()
}
tools {
maven 'maven-3'
}
// MAJOR und MINOR werden manuell als Jenkins-Parameter gepflegt.
// BUILD_NUMBER wird automatisch durch Jenkins vergeben.
// Die resultierende Versionsnummer lautet: MAJOR.MINOR.BUILD_NUMBER
parameters {
string(name: 'MAJOR', defaultValue: '3', description: 'SemVer MAJOR (manuell)')
string(name: 'MINOR', defaultValue: '0', description: 'SemVer MINOR (manuell)')
}
stages {
stage('Version bestimmen') {
steps {
script {
def isManual = !currentBuild.getBuildCauses('hudson.model.Cause$UserIdCause').isEmpty()
def jenkinsHome = env.JENKINS_HOME ?: '/var/jenkins_home'
def safeJobName = env.JOB_NAME.replaceAll(/[^A-Za-z0-9._-]/, '_')
def stateDir = "${jenkinsHome}/version-state"
def stateFile = "${stateDir}/${safeJobName}.properties"
if (isManual) {
env.EFFECTIVE_MAJOR = params.MAJOR
env.EFFECTIVE_MINOR = params.MINOR
sh """
mkdir -p '${stateDir}'
cat > '${stateFile}' <<'EOF'
MAJOR=${params.MAJOR}
MINOR=${params.MINOR}
EOF
"""
echo "Manueller Build erkannt. Version gespeichert: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
} else {
def stateExists = (sh(script: "[ -f '${stateFile}' ]", returnStatus: true) == 0)
if (stateExists) {
env.EFFECTIVE_MAJOR = sh(
script: "grep '^MAJOR=' '${stateFile}' | cut -d= -f2-",
returnStdout: true
).trim()
env.EFFECTIVE_MINOR = sh(
script: "grep '^MINOR=' '${stateFile}' | cut -d= -f2-",
returnStdout: true
).trim()
echo "Automatischer Build erkannt. Gespeicherte Version verwendet: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
} else {
env.EFFECTIVE_MAJOR = params.MAJOR
env.EFFECTIVE_MINOR = params.MINOR
echo "Automatischer Build ohne gespeicherten Stand. Fallback auf Parameter: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
}
}
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
}
}
} // stage: Version bestimmen
stage('Maven Build') {
steps {
catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') {
// -Drevision übergibt die vollständige Versionsnummer an Maven.
// Das flatten-maven-plugin im Parent-POM löst ${revision} in
// allen installierten POMs auf.
sh "mvn clean verify -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER}"
}
}
} // stage: Maven Build
stage('Publish PIT Coverage') {
steps {
recordCoverage(
tools: [[
parser: 'PIT',
pattern: '**/target/pit-reports/mutations.xml'
]],
id: 'pit',
name: 'PIT Mutation Coverage',
failOnError: true
)
}
} // stage: Publish PIT Coverage
stage('Archive JAR') {
steps {
// Bash wird explizit erzwungen, weil Jenkins-Agenten standardmäßig
// sh (dash) verwenden, das kein mapfile kennt. mapfile zählt exakt
// die gefundenen Shade-JARs und bricht ab, wenn nicht genau eines vorhanden ist.
sh '''#!/usr/bin/env bash
set -euo pipefail
mapfile -t JARS < <(find pdf-umbenenner-bootstrap/target \
-maxdepth 1 -name "pdf-umbenenner-bootstrap-*.jar" \
! -name "*-sources.jar" ! -name "*-javadoc.jar")
test "${#JARS[@]}" -eq 1 \
|| { echo "FEHLER: Erwartet genau 1 Shade-JAR, gefunden: ${#JARS[@]}"; exit 1; }
JAR_NAME="pdf-ki-renamer-${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}.jar"
cp "${JARS[0]}" "$JAR_NAME"
echo "Shade-JAR archiviert als: $JAR_NAME"
'''
archiveArtifacts artifacts: 'pdf-ki-renamer-*.jar', fingerprint: true
}
} // stage: Archive JAR
stage('Artefakt ablegen') {
steps {
sh '''#!/usr/bin/env bash
set -euo pipefail
BUILD_DIR="/builds/${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}"
mkdir -p "$BUILD_DIR"
cp pdf-ki-renamer-*.jar "$BUILD_DIR/"
echo "Artefakt abgelegt unter: $BUILD_DIR"
'''
}
} // stage: Artefakt ablegen
stage('Berichte veröffentlichen') {
steps {
junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true
recordCoverage(
tools: [[parser: 'JACOCO', pattern: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate/jacoco.xml']],
enabledForFailure: true
)
publishHTML(target: [
reportName: 'JaCoCo HTML Report',
reportDir: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate',
reportFiles: 'index.html',
keepAll: true,
alwaysLinkToLastBuild: true,
allowMissing: true
])
}
} // stage: Berichte veröffentlichen
stage('Aufräumen') {
steps {
sh '''#!/usr/bin/env bash
set -euo pipefail
rm -f pdf-ki-renamer-*.jar
echo "Aufräumen abgeschlossen."
'''
}
} // stage: Aufräumen
} // stages
post {
success {
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} erfolgreich abgeschlossen."
}
failure {
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} fehlgeschlagen."
}
always {
deleteDir()
}
}
} // pipeline
+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`
+8 -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+)
@@ -348,3 +348,8 @@ das Fenster beim Start automatisch maximiert wird.
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.
+110 -2
View File
@@ -63,7 +63,7 @@ mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
### Umfang der GUI
Die GUI enthält zwei Tabs:
Die GUI enthält drei Tabs:
- **Tab „Konfiguration"** Editor, Validierungs- und technische Testoberfläche für
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
@@ -75,6 +75,13 @@ Die GUI enthält zwei Tabs:
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
- **Tab „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
@@ -189,7 +196,7 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in:
| `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) |
| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) |
| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) |
| `max.title.length` | Maximale Länge des Basistitels in Zeichen (ganzzahlig, 10..120, Default 60). Werte unter 10 oder über 120 verhindern den Start. Werte 1019 und 100120 erzeugen eine Startwarnung. |
| `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
@@ -292,6 +299,35 @@ Die Anwendung ergänzt den Prompt automatisch um:
- einen Dokumenttext-Abschnitt
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
### Prompt-Pfad-Auflösung je Betriebsart
Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst,
wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab:
| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert |
|---|---|---|
| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` |
| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` |
| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` |
| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen |
> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad**
> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis
> immer eindeutig auffindbar insbesondere beim Start über den Windows Task Scheduler,
> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann.
### Bearbeitung über den GUI-Prompt-Tab
Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und
gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn
das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem
Fall wird kein stiller Fallback durchgeführt).
Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert
war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"),
aktualisiert sich der Prompt-Tab nicht automatisch in diesem Fall sollte die GUI neu gestartet
oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden.
---
## Zielformat
@@ -451,6 +487,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
@@ -510,6 +550,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):
+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.
+370 -25
View File
@@ -1,4 +1,4 @@
# GUI-Bedienanleitung PDF-Umbenenner V2.0
# GUI-Bedienanleitung PDF-Umbenenner
Diese Anleitung beschreibt die JavaFX-Desktop-GUI des PDF-Umbenenners. Sie richtet sich an
Endbenutzer und Betreuer, die die Konfiguration der Anwendung über die grafische Oberfläche
@@ -8,18 +8,21 @@ verwalten und technisch prüfen möchten.
## 1. Zweck und Scope der GUI
Die GUI gliedert sich in zwei feste Tabs:
Die GUI gliedert sich in vier feste Tabs:
- **Tab 1 „Konfiguration"** Editor, Validierungsoberfläche und technische
Test-/Diagnoseoberfläche für die `.properties`-Datei.
- **Tab 2 „Verarbeitungslauf"** Start eines Batch-Laufs aus der GUI mit
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
- **Tab 3 „Verlauf"** Ansicht aller bisher verarbeiteten Dokumente mit Status
und Verarbeitungsdetails aus der SQLite-Datenbank (siehe Abschnitt 16).
- **Tab 4 „Prompt"** Editor zum Lesen, Bearbeiten und Speichern der
konfigurierten KI-Prompt-Datei (siehe Abschnitt 17).
Weiterhin **nicht** enthalten sind ein Historien-Tab, eine Datenbankansicht und ein
Kosten-Tracking — diese Ausbauten sind für spätere Stufen vorbehalten.
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 18).
Der headless Batch-/Scheduler-Betrieb über `--headless` bleibt der einzige Weg,
PDF-Dateien automatisiert zu verarbeiten.
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
`--headless` der empfohlene Weg.
---
@@ -468,15 +471,31 @@ in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein.
- 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 Fehler- und Übersprungen-Fälle wird bei den Spalten „Neuer Dateiname" und „Datum"
ein Gedankenstrich `—` eingetragen.
- Die Status-Icons folgen: ✅ erfolgreich, ⚠️ fehlgeschlagen (retryable),
❌ fehlgeschlagen (permanent), ⏭️ übersprungen.
- Ein Klick auf eine Zeile zeigt die KI-Begründung im Seitenbereich. Liegt keine
Begründung vor, erscheint der Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning
vor.".
- Nach Laufende erscheint die Zusammenfassung `X erfolgreich, Y fehlgeschlagen,
Z übersprungen` im Meldungs- und Zusammenfassungsbereich.
- Für `FAILED_*`-Zeilen und `SKIPPED_FINAL_FAILURE`-Zeilen wird in den Spalten
„Neuer Dateiname" und „Datum" ein Gedankenstrich `—` eingetragen.
`SKIPPED_ALREADY_PROCESSED`-Zeilen zeigen in der Spalte „Neuer Dateiname" den
historischen Zieldateinamen aus dem letzten erfolgreichen Lauf; „Datum" bleibt `—`.
- Status-Icons (Unicode-Zeichen mit Farbe):
| Symbol | Farbe | Bedeutung |
|--------|-------|-----------|
| `✓` | Grün | Erfolgreich |
| `↻` | Orange | Fehlgeschlagen (wiederholbar) |
| `×` | Rot | Fehlgeschlagen (permanent) |
| `≡` | Grau | Übersprungen (bereits erfolgreich verarbeitet) |
| `⊘` | Dunkelgrau | Übersprungen (endgültig fehlgeschlagen) |
| `⟳` | Grau | Zurückgesetzt wartet auf nächsten Lauf |
Farbe ist niemals das einzige Unterscheidungsmerkmal Icon und Tooltip beschreiben
den Status auch ohne Farbwahrnehmung eindeutig. Die vollständige Status-Mapping-Tabelle
mit Tooltips ist in Abschnitt 19 beschrieben.
- Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge
zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des
KI-Reasonings. Liegt weder Reasoning noch Fehlermeldung vor, erscheint der
Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning vor.".
- Nach Laufende erscheint das **Summary-Banner** unterhalb des Fortschrittsbalkens
(siehe Abschnitt 13c).
### Soft-Stop
Der Knopf **Abbrechen** löst einen **Soft-Stop** aus: die aktuell in Bearbeitung
@@ -604,18 +623,43 @@ Das Panel enthält drei Bereiche:
(preserveRatio=true). Keine Scrollbalken, keine manuelle Zoom-Einstellung.
- Das Rendering erfolgt direkt über Apache PDFBox bei 120 DPI.
### KI-Begründung
### KI-Begründung und Fehlertext
Der mittlere Bereich zeigt das KI-Reasoning des ausgewählten Eintrags. Liegt kein
Reasoning vor (z. B. bei Übersprungen-Einträgen), erscheint der Hinweis
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 aktuellen Dateinamen des Eintrags.
- Das Feld kann direkt bearbeitet werden. Die Eingabe wird **live validiert**
(Formatprüfung `YYYY-MM-DD - Titel.pdf`, Titelzeichen, Länge).
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.
@@ -629,13 +673,314 @@ Reasoning vor (z. B. bei Übersprungen-Einträgen), erscheint der Hinweis
---
## 14. Bekannte Einschränkungen V2.x
## 13c. Summary-Banner nach Laufabschluss
Nach Abschluss eines Verarbeitungslaufs erscheint unterhalb des Fortschrittsbalkens und
oberhalb der Ergebnistabelle ein einzeiliges **Summary-Banner** (`BatchRunSummaryBanner`).
Es zeigt auf einen Blick, wie viele Dateien in welche Kategorie gefallen sind.
**Beispiel:**
```
✓ 14 erfolgreich · ↻ 1 wird wiederholt · × 2 fehlgeschlagen · ≡ 3 übersprungen · ⊘ 1 endgültig übersprungen
```
**Regeln:**
- Nur Kategorien mit Anzahl größer als 0 werden angezeigt.
- Bei einem vollständig sauberen Lauf erscheint nur die Erfolgskategorie,
z. B. `✓ 17 erfolgreich`.
- Jedes Segment enthält Icon und Text Farbe ist niemals das einzige Unterscheidungsmerkmal.
- Das Banner verschwindet automatisch, wenn der nächste Lauf gestartet wird.
- Das Banner erscheint **nicht** beim Anwendungsstart oder bei einem Tab-Wechsel
ohne vorherigen Lauf.
**Kategorien:**
| Icon | Text | Entsprechender Status |
|------|------|-----------------------|
| `✓` | erfolgreich | `SUCCESS` |
| `↻` | wird wiederholt | `FAILED_RETRYABLE` |
| `×` | fehlgeschlagen | `FAILED_FINAL` |
| `≡` | übersprungen | `SKIPPED_ALREADY_PROCESSED` |
| `⊘` | endgültig übersprungen | `SKIPPED_FINAL_FAILURE` |
Die Zwischenstatus `READY_FOR_AI`, `PROPOSAL_READY` und `PROCESSING` werden im Banner
nicht gezählt sie treten nach Laufabschluss nicht mehr auf.
---
## 14. Bekannte Einschränkungen
| Einschränkung | Erläuterung |
|---|---|
| Kein Historien-Tab | Eine Ansicht der SQLite-Datenbank und Verarbeitungshistorie ist für spätere Ausbaustufen vorbehalten |
| Kein Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten |
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer |
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. Die dauerhaften Ergebnisse sind im Verlauf-Tab (Abschnitt 16) einsehbar. |
| Einzelinstanz-Schutz | Wird die Anwendung ein zweites Mal gestartet, während bereits eine Instanz läuft (auch wenn diese im System-Tray minimiert ist), beendet sich die neue Instanz sofort ohne Hinweisfenster |
| Prompt-Editor: kein automatisches Reload | Wird die Prompt-Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht. Beim Speichern gilt Last-write-wins. |
---
## 15. System-Tray
Wird das Hauptfenster über das Schließen-Symbol (oder Alt+F4) geschlossen, ohne dass
ungespeicherte Änderungen oder ein aktiver Verarbeitungslauf vorliegen, **minimiert
sich die Anwendung in den Windows System-Tray** statt sich zu beenden. Das Fenster
bleibt im Hintergrund aktiv und ist über das Tray-Icon wieder erreichbar.
### 15.1 Tray-Icon-Menü
Ein **Rechtsklick** auf das Tray-Icon öffnet ein Kontextmenü:
| Eintrag | Wirkung |
|---------|---------|
| **Öffnen** | Bringt das Hauptfenster in den Vordergrund |
| **Beenden** | Beendet die Anwendung vollständig |
Ein **Doppelklick** auf das Tray-Icon hat denselben Effekt wie „Öffnen".
### 15.2 Sonderfälle beim Schließen
| Situation | Verhalten |
|---|---|
| Ungespeicherte Änderungen | Schutzdialog erscheint zuerst (Speichern / Verwerfen / Abbrechen); erst nach Auflösung wird in den Tray minimiert |
| Aktiver Verarbeitungslauf | Hinweisdialog erscheint (Abschnitt 13); nach Soft-Stop oder Abschluss kann in den Tray minimiert werden |
| System-Tray nicht verfügbar | Fenster wird beim Schließen wie ohne Tray-Support behandelt; der Schutzdialog für ungespeicherte Änderungen bleibt aktiv |
---
## 16. Tab „Verlauf" (Historien-Tab)
Der dritte Tab **„Verlauf"** zeigt alle jemals verarbeiteten Dokumente mit Status,
Dateinamen und Verarbeitungsdetails. Die Daten stammen direkt aus der SQLite-Datenbank,
die in der geladenen Konfiguration angegeben ist.
### Layout
Das Tab ist zweigeteilt:
- **Linke Hälfte (~55%):** Dokumentenliste mit Filter-Bereich oben
- **Rechte Hälfte (~45%):** Detailbereich zum ausgewählten Dokument
### Dokumentenliste
Die Tabelle zeigt folgende Spalten:
| Spalte | Inhalt |
|--------|--------|
| Status-Icon | Symbol und Farbe gemäß Status-Mapping-Tabelle (Abschnitt 19) |
| Quelldateiname | Ursprünglicher Dateiname der PDF-Datei |
| Zieldateiname | Zuletzt vergebener Dateiname nach Umbenennung |
| Quellpfad | Letzter bekannter Quellordner |
| Letzter Versuch | Zeitpunkt der letzten Statusänderung |
| Anzahl Versuche | Gesamtzahl aller Verarbeitungsversuche |
**Sortierung:** Standardmäßig absteigend nach dem letzten Versuch (neueste zuerst).
**Hinweise zur Anzeige:**
- Lange Dateinamen und Pfade werden in der Tabelle abgekürzt (Ellipsis). Der vollständige
Text erscheint im Tooltip beim Hover.
- Bei mehr als 500 Treffern erscheint der Hinweis „Weitere Einträge vorhanden Filter
verwenden". Es werden dann nur die 500 neuesten Einträge angezeigt.
- Bei leerer Datenbank erscheint der Text „Noch keine Verarbeitungen vorhanden."
### Filter
Über dem Tab befinden sich drei Bedienelemente:
- **Freitextsuche** filtert über Quelldateiname und Zieldateiname, case-insensitiv
- **Status-Filter** ComboBox zur Auswahl eines bestimmten Status oder „Alle"
- **„Aktualisieren"** lädt die Liste neu aus der Datenbank (kein automatisches Echtzeit-Tailing)
Die Suche erfolgt datenbanksseitig; Sonderzeichen in der Sucheingabe werden korrekt behandelt.
### Detailbereich
Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
**Dokument-Info:**
- Fingerprint (12 Zeichen des SHA-256-Hash)
- Quelldateiname und Quellpfad
- Status (Icon + Text)
- Erstellt am / Aktualisiert am
**Versuche-Tabelle:** Alle bisher protokollierten Verarbeitungsversuche:
| Spalte | Inhalt |
|--------|--------|
| # | Versuchsnummer |
| Datum | Endzeitpunkt des Versuchs |
| Status | Ergebnisstatus des Versuchs |
| Provider | Verwendeter KI-Provider |
| Modell | Verwendetes Sprachmodell |
| Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname |
**KI-Begründung:** Das `ai_reasoning` des ausgewählten Versuchs als nicht editierbarer Text.
### Aktionen
Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung:
**„Status zurücksetzen"**
Setzt den Status des ausgewählten Dokuments auf „Wartet auf Verarbeitung" zurück,
sodass es beim nächsten Verarbeitungslauf automatisch erneut verarbeitet wird.
Die Versuchshistorie bleibt vollständig erhalten kein Versuch wird gelöscht.
Vor der Aktion erscheint ein Bestätigungsdialog.
Wann sinnvoll: wenn die Ursache eines Fehlers behoben wurde (z. B. OCR nachträglich
durchgeführt, Passwortschutz entfernt) und das Dokument erneut verarbeitet werden soll.
**„Eintrag löschen"**
Löscht den Stammsatz und alle Verarbeitungsversuche des ausgewählten Dokuments
vollständig aus der Datenbank. Diese Aktion ist **nicht rückgängig zu machen**.
Vor der Aktion erscheint ein Bestätigungsdialog mit einem ausdrücklichen Hinweis
auf die Unwiderruflichkeit.
**Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert.
Ein Hinweis „Aktion während Verarbeitungslauf nicht möglich." wird angezeigt.
---
## 17. Tab „Prompt" (Prompt-Editor)
Der vierte Tab **„Prompt"** ermöglicht das Lesen, Bearbeiten und Speichern der
KI-Prompt-Datei direkt in der GUI ohne externen Editor.
### Inhalt und Bedienung
Die TextArea zeigt den aktuellen Inhalt der in der Konfiguration eingetragenen
Prompt-Datei. Der Inhalt ist vollständig editierbar.
**Buttons:**
- **„Speichern"** schreibt den aktuellen Inhalt atomar in die Prompt-Datei
(Temp-Datei im selben Verzeichnis, dann atomarer Austausch). Encoding: UTF-8;
Zeilenenden werden unverändert übernommen. Bei einem Fehler erscheint eine
Fehlermeldung im Tab; es gibt keinen stillen Fallback.
- **„Auf Standard zurücksetzen"** füllt die TextArea mit dem eingebauten
Standard-Template, ohne die Datei sofort zu speichern. Erst ein anschließendes
„Speichern" schreibt die Änderung auf den Datenträger.
**Dirty State:**
Sobald der TextArea-Inhalt vom gespeicherten Stand abweicht, erscheint ein
Asterisk im Tab-Titel: **„Prompt \*"**. Wird der Tab gewechselt oder die
Anwendung geschlossen, während ungespeicherte Änderungen vorliegen, erscheint
ein Bestätigungsdialog mit der Frage „Änderungen verwerfen?".
### Fehlende Prompt-Datei
Ist keine Prompt-Datei konfiguriert oder existiert die konfigurierte Datei nicht,
zeigt der Tab einen Hinweistext und den Button **„Standard-Prompt erstellen"**.
Ein Klick legt eine Prompt-Datei mit dem deutschen Standard-Template an
(standardmäßig im selben Ordner wie die geladene `.properties`-Datei).
### Hinweise
- Das Tab lädt den Dateiinhalt automatisch, wenn es geöffnet wird (Hintergrund-Thread).
- Wird die Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht.
Beim Speichern gilt Last-write-wins.
- Für den Betrieb über MSI oder Task Scheduler wird empfohlen, den Prompt-Pfad
in der Konfiguration als absoluten Pfad anzugeben, um vom jeweiligen Arbeitsverzeichnis
unabhängig zu sein.
---
## 18. Statuszeile
Am unteren Rand des Hauptfensters ist permanent eine **Statuszeile** (`GuiStatusBar`)
sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
| Position | Inhalt | Beispiel |
|----------|--------|---------|
| Links | Anwendungsversion | `V3.0.42` |
| Mitte | Aktiver Provider und Modell | `Provider: Claude · claude-opus-4-7` |
| Rechts | Pfad der geladenen Konfigurationsdatei | `config/application.properties` |
**Besonderheiten:**
- Die Versionsangabe wird aus der JAR-Manifest-Datei gelesen. Beim Start aus einer IDE
ohne gepacktes JAR erscheint der Fallback `Vdev`.
- Ist keine Konfiguration geladen, zeigen Mitte und Rechts den Text „Kein Profil geladen".
- Die Statuszeile aktualisiert sich automatisch beim Laden, Speichern und Schließen
einer Konfigurationsdatei.
---
## 19. Fehlerstatus Bedeutung und Unterscheidung
Zwei Fehlerstatus werden in der GUI klar unterschieden. Die Unterscheidung ist wichtig,
um zu entscheiden, ob eine erneute Verarbeitung sinnvoll ist.
### `↻` Wird wiederholt (orange) `FAILED_RETRYABLE`
Das Dokument konnte vorübergehend nicht verarbeitet werden. Der Fehler ist
wahrscheinlich technischer Natur und kann sich bei einem späteren Versuch
von selbst auflösen.
**Typische Ursachen:** Netzwerkfehler, Timeout beim KI-Dienst, vorübergehende
Nicht-Erreichbarkeit.
**Was passiert:** Das Dokument wird beim nächsten regulären Verarbeitungslauf
**automatisch erneut versucht** kein manuelles Eingreifen notwendig.
### `×` Fehlgeschlagen (rot) `FAILED_FINAL`
Das Dokument ist dauerhaft nicht verarbeitbar. Automatische Wiederholversuche
werden nicht mehr unternommen.
**Typische Ursachen:**
- Kein lesbarer Text (z. B. eingescanntes Foto ohne OCR-Verarbeitung)
- Passwortgeschützte PDF
- Beschädigte oder unlesbare Datei
**Was passiert:** Das Dokument wird in späteren Läufen übersprungen.
**Mögliche Abhilfe:** Wenn die Ursache behoben wurde (z. B. OCR wurde nachträglich
durchgeführt), kann der Status im **Verlauf-Tab** (Abschnitt 16) manuell zurückgesetzt
werden. Das Dokument wird dann beim nächsten Lauf erneut verarbeitet. Alternativ kann
der Eintrag vollständig gelöscht werden, damit die Datei als neu erkannt wird.
---
### Vollständige Status-Mapping-Tabelle
| Status | Icon | Farbe | Tooltip-Text | Summary-Kategorie |
|--------|------|-------|-------------|-------------------|
| `SUCCESS` | `✓` | Grün | „Erfolgreich verarbeitet und umbenannt." | erfolgreich |
| `FAILED_RETRYABLE` | `↻` | Orange | „Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht." | wird wiederholt |
| `FAILED_FINAL` | `×` | Rot | „Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch." | fehlgeschlagen |
| `SKIPPED_ALREADY_PROCESSED` | `≡` | Grau | „Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet." | übersprungen |
| `SKIPPED_FINAL_FAILURE` | `⊘` | Dunkelgrau | „Endgültig übersprungen nach wiederholten Fehlern." | endgültig übersprungen |
| `READY_FOR_AI` | `⟳` | Blau | „Wartet auf Verarbeitung." | |
| `PROPOSAL_READY` | `◇` | Hellblau | „KI-Vorschlag liegt vor, wartet auf Bestätigung." | |
| `PROCESSING` | `▶` | Hellgrau | „Wird gerade verarbeitet." | |
**Wichtig:** Farbe ist niemals das einzige Unterscheidungsmerkmal. Icon und Tooltip-Text
beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
---
## 20. Tooltips
Auf den meisten interaktiven Elementen der GUI sind Tooltips gesetzt, die beim Hover über
ein Element erscheinen. Sie erklären kurz den Zweck des Elements.
Tooltips sind unter anderem vorhanden auf:
- **Konfigurationsfeldern** Quellordner, Zielordner, SQLite-Datei, Prompt-Datei,
Provider-ComboBox, Modell-Feld, `max.text.characters`, `max.pages`, `max.title.length`
- **Toolbar-Buttons** Neu, Öffnen, Speichern, Speichern unter, Validieren,
Technische Tests ausführen
- **Status-Icons** im Verarbeitungslauf-Tab Text gemäß Status-Mapping-Tabelle
(Abschnitt 19)
- **Buttons „Dateiname übernehmen"** und **„Zurücksetzen auf KI-Vorschlag"** im
Dateiname-Editor (Abschnitt 13b)
Der Tooltip erscheint nach einer kurzen Verzögerung beim Verweilen mit der Maus
über dem jeweiligen Element.
File diff suppressed because it is too large Load Diff
+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>
+1 -1
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>
@@ -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);
}
@@ -8,6 +8,8 @@ import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.prefs.Preferences;
@@ -70,6 +72,8 @@ import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TextField;
import javafx.scene.control.TitledPane;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.KeyCode;
@@ -169,6 +173,20 @@ public final class GuiConfigurationEditorWorkspace {
private final GuiConfigurationFileLoader configurationFileLoader;
private final GuiConfigurationFileWriter configurationFileWriter;
/**
* Serialisiert Lade-Auftraege fuer Konfigurationsdateien, sodass mehrere schnell
* aufeinanderfolgende Oeffnen-Aktionen (z.B. Doppelklick) keine konkurrierenden
* Worker-Threads erzeugen. Der Executor ist einzel-threadig: Auftraege werden der
* Reihe nach abgearbeitet. Der zugrundeliegende Thread ist als Daemon konfiguriert,
* damit die JVM beim Beenden nicht blockiert wird.
*/
private final ExecutorService configLoaderExecutor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "gui-config-loader");
thread.setDaemon(true);
return thread;
});
/**
* The current editor state. Package-private to allow direct state injection in smoke tests
* that need to set a specific dirty state without going through the full load/save pipeline.
@@ -235,17 +253,16 @@ public final class GuiConfigurationEditorWorkspace {
this::showNativeDirectoryChooser;
/**
* Dialog function for file-picker buttons; package-private to allow substitution in tests.
* Receives the dialog title and the current field text (used as an initial path hint), and
* returns the selected absolute path string or {@code null} when the dialog is cancelled.
* Dialog-Hook fuer Datei-Auswaehldialoge; package-private, damit Tests eine Stub-Implementierung
* injizieren koennen. Empfaengt Titel, aktuellen Feldtext (als Anfangspfad-Hinweis) und die
* Liste der Dateitypfilter; gibt den ausgewaehlten absoluten Pfad oder {@code null} zurueck.
* <p>
* The default implementation opens a native {@link FileChooser}. Tests may replace
* this with a lambda that returns a fixed string without opening a native dialog.
* <p>
* Extension filters are applied by the default implementation only; test stubs bypass them.
* Die Standardimplementierung oeffnet einen nativen {@link FileChooser} und wendet die
* uebergebenen Filter an. Test-Stubs koennen die Filter ignorieren und einen festen Pfad
* zurueckgeben.
*/
java.util.function.BiFunction<String, String, String> filePickerDialog =
(title, initialPath) -> showNativeFileChooser(title, initialPath);
FilePickerDialog filePickerDialog =
(title, initialPath, filters) -> showNativeFileChooser(title, initialPath, filters);
/**
* Guard that mediates the protection dialog before destructive actions.
@@ -260,6 +277,15 @@ public final class GuiConfigurationEditorWorkspace {
*/
Consumer<String> titleUpdateListener = title -> { };
/**
* Listener der bei jedem Zustandswechsel des Editor-Zustands aufgerufen wird und
* den neuen Zustand an die Statuszeile weiterleitet.
* <p>
* Package-private, damit {@link PdfUmbenennerGuiApplication} die Statuszeile verdrahten kann.
* Standard ist ein No-Op, damit der Workspace auch ohne Statuszeile funktioniert.
*/
Consumer<GuiConfigurationEditorState> statusBarStateListener = state -> { };
/**
* Per-provider {@link GuiModelFieldContainer} instances, one for each known provider family.
* Populated in {@link #createProviderBlock(String, AiProviderFamily)} and registered with
@@ -388,6 +414,14 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
/**
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort}
* erzeugt. Wird verwendet, wenn eine neue Konfiguration geladen oder gespeichert wird,
* um den {@link GuiPromptEditorTab} mit einem aktualisierten Port zu versorgen.
* Supplied by Bootstrap via the startup context.
*/
private final GuiPromptEditorPortFactory promptEditorPortFactory;
/**
* Second main tab of the window that drives the live processing-run view. Created
* during workspace construction and wired into the shared {@link #tabPane} alongside
@@ -395,6 +429,18 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiBatchRunTab batchRunTab;
/**
* Dritter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion
* erstellt und in den {@link #tabPane} eingehängt.
*/
private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab;
/**
* Vierter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
* und in den {@link #tabPane} eingehängt.
*/
private final GuiPromptEditorTab promptEditorTab;
/**
* Hint banner shown at the top of the configuration tab while a processing run is
* active. Visible + managed state are flipped from the batch run tab's listener when
@@ -449,6 +495,7 @@ public final class GuiConfigurationEditorWorkspace {
() -> editorState.loadedFileSnapshot()
.map(snapshot -> snapshot.filePath().toString())
.orElse(""),
() -> editorState.values().logDirectory(),
pendingMessages,
report -> {
technicalTestsButton.setDisable(false);
@@ -465,6 +512,7 @@ public final class GuiConfigurationEditorWorkspace {
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
() -> this.miniRunLauncher,
@@ -478,6 +526,25 @@ public final class GuiConfigurationEditorWorkspace {
this::editorSourceFolder,
this::editorTargetFolder);
this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab(
effectiveContext.historyOverviewPort(),
effectiveContext.historyDetailsPort(),
effectiveContext.historyResetDocumentStatusPort(),
effectiveContext.deleteDocumentHistoryPort(),
this.batchRunTab::isRunning,
this::loadedConfigurationPath);
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
int maxTitleLength;
try {
maxTitleLength = Integer.parseInt(
effectiveContext.initialState().values().maxTitleLength().trim());
} catch (NumberFormatException e) {
maxTitleLength = 60;
}
this.promptEditorTab = new GuiPromptEditorTab(
effectiveContext.promptEditorPort(), configuredPromptPath, maxTitleLength);
configureRoot();
configureHeader(effectiveContext.startupNotice());
configureTabs();
@@ -872,7 +939,9 @@ public final class GuiConfigurationEditorWorkspace {
return;
}
Thread worker = new Thread(() -> {
// Lade-Auftrag wird seriell ueber den Single-Thread-Executor eingereicht, um
// Race Conditions durch gleichzeitig laufende Lade-Threads zu vermeiden.
configLoaderExecutor.submit(() -> {
try {
GuiConfigurationEditorState loadedState = configurationFileLoader.load(configFilePath);
// Speichern des Pfads als letzte geladene Konfiguration
@@ -882,9 +951,7 @@ public final class GuiConfigurationEditorWorkspace {
Platform.runLater(() -> showError("Konfiguration konnte nicht geladen werden: "
+ safeMessage(exception)));
}
}, "gui-config-loader");
worker.setDaemon(true);
worker.start();
});
}
/**
@@ -1078,6 +1145,10 @@ public final class GuiConfigurationEditorWorkspace {
this.editorState = completion.newState();
refreshHeader();
// Statuszeile nach erfolgreichem Speichern aktualisieren (Konfigurationspfad kann neu sein)
statusBarStateListener.accept(this.editorState);
// Prompt-Tab über neuen Prompt-Pfad informieren (kann sich durch Speichern geändert haben)
notifyPromptTabConfigChanged(this.editorState);
if (result.hasApiKeyPreservationNote()) {
LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
@@ -1173,6 +1244,63 @@ public final class GuiConfigurationEditorWorkspace {
pendingMessages.clear();
refreshView();
runEditorValidation();
// Statuszeile über den neuen Zustand informieren
statusBarStateListener.accept(newState);
// Prompt-Tab mit neuem Pfad und Port versorgen
notifyPromptTabConfigChanged(newState);
}
/**
* Benachrichtigt den Prompt-Editor-Tab über eine geänderte Konfiguration.
* <p>
* Liest {@code prompt.template.file} und {@code max.title.length} aus dem neuen
* Zustand, erzeugt über die Factory einen passenden Port und übergibt beides an den
* Tab. Ist der Prompt-Pfad leer, wird ein No-Op-Port verwendet.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param state der neue Editor-Zustand; darf nicht {@code null} sein
*/
private void notifyPromptTabConfigChanged(de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState state) {
String promptPath = state.values().promptTemplateFile();
int maxTitle;
try {
maxTitle = Integer.parseInt(state.values().maxTitleLength().trim());
} catch (NumberFormatException e) {
maxTitle = 60;
}
GuiPromptEditorPort newPort = (promptPath != null && !promptPath.isBlank())
? promptEditorPortFactory.create(promptPath)
: noOpPromptEditorPort();
promptEditorTab.notifyConfigurationChanged(
newPort,
promptPath != null ? promptPath : "",
maxTitle);
}
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_PATH", "Kein Prompt-Pfad konfiguriert.");
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
"Kein Prompt-Pfad konfiguriert.", null);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDefaultPromptIfMissing(
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Prompt-Pfad konfiguriert.");
}
};
}
private void configureRoot() {
@@ -1233,16 +1361,20 @@ public final class GuiConfigurationEditorWorkspace {
ScrollPane scrollPane = new ScrollPane(tabContent);
scrollPane.setFitToWidth(true);
// FitToHeight sorgt dafür, dass tabContent den sichtbaren Viewport ausfüllt;
// nur so kann VBox.setVgrow auf einzelnen Sektionen (z.B. Meldungen) wirken.
scrollPane.setFitToHeight(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setPadding(new Insets(0));
editorTab.setContent(scrollPane);
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), historyTab.tab(), promptEditorTab.tab());
root.setCenter(tabPane);
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
// der Dateiname-Editor ungespeicherte Änderungen hat.
// Gleiches gilt für den Prompt-Tab.
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
if (oldTab == null || newTab == null) {
return;
@@ -1254,11 +1386,24 @@ public final class GuiConfigurationEditorWorkspace {
// Zurück zum Verarbeitungslauf-Tab
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
}
} else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) {
boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty();
if (!shouldDiscard) {
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
} else {
promptEditorTab.discardChanges();
}
}
});
}
private void configureActionBar() {
// Tooltips für Toolbar-Buttons gemäß Spezifikation
applyTooltip(newButton, GuiTooltipTexts.TOOLBAR_NEU);
applyTooltip(openButton, GuiTooltipTexts.TOOLBAR_OEFFNEN);
applyTooltip(saveButton, GuiTooltipTexts.TOOLBAR_SPEICHERN);
applyTooltip(saveAsButton, GuiTooltipTexts.TOOLBAR_SPEICHERN_UNTER);
HBox actionBar = new HBox(10, newButton, openButton, saveButton, saveAsButton);
actionBar.setAlignment(Pos.CENTER_LEFT);
actionBar.setPadding(new Insets(16, 0, 0, 0));
@@ -1346,12 +1491,14 @@ public final class GuiConfigurationEditorWorkspace {
TextField sourceFolderField = boundTextField(
editorState.values().sourceFolder(),
val -> updateValues(editorState.values().withSourceFolder(val)));
applyTooltip(sourceFolderField, GuiTooltipTexts.PFADE_QUELLORDNER);
Label sourceFolderErrorLabel = createFieldErrorLabel();
fieldErrorLabels.put("source.folder", sourceFolderErrorLabel);
TextField targetFolderField = boundTextField(
editorState.values().targetFolder(),
val -> updateValues(editorState.values().withTargetFolder(val)));
applyTooltip(targetFolderField, GuiTooltipTexts.PFADE_ZIELORDNER);
Label targetFolderErrorLabel = createFieldErrorLabel();
fieldErrorLabels.put("target.folder", targetFolderErrorLabel);
@@ -1376,12 +1523,14 @@ public final class GuiConfigurationEditorWorkspace {
TextField sqliteField = boundTextField(
editorState.values().sqliteFile(),
val -> updateValues(editorState.values().withSqliteFile(val)));
applyTooltip(sqliteField, GuiTooltipTexts.PFADE_SQLITE);
Label sqliteErrorLabel = createFieldErrorLabel();
fieldErrorLabels.put("sqlite.file", sqliteErrorLabel);
TextField promptField = boundTextField(
editorState.values().promptTemplateFile(),
val -> updateValues(editorState.values().withPromptTemplateFile(val)));
applyTooltip(promptField, GuiTooltipTexts.PFADE_PROMPT);
Label promptErrorLabel = createFieldErrorLabel();
fieldErrorLabels.put("prompt.template.file", promptErrorLabel);
@@ -1469,6 +1618,7 @@ public final class GuiConfigurationEditorWorkspace {
providerComboBox.setConverter(new AiProviderFamilyStringConverter());
providerComboBox.getItems().addAll(AiProviderFamily.CLAUDE, AiProviderFamily.OPENAI_COMPATIBLE);
providerComboBox.setValue(initialProvider);
applyTooltip(providerComboBox, GuiTooltipTexts.PROVIDER_COMBOBOX);
// --- "Modelle neu laden" button ---
Button reloadModelsButton = new Button("Modelle neu laden");
@@ -1646,9 +1796,10 @@ public final class GuiConfigurationEditorWorkspace {
private VBox createProviderBlock(String displayName, AiProviderFamily family) {
String ns = "ai.provider." + family.getIdentifier() + ".";
// Kompaktere Provider-Box: VBox-Spacing 2 für engere Feldabstände, unteres Padding 4px
VBox block = new VBox(2);
block.setStyle(
"-fx-padding: 6px; -fx-border-color: #c8c8c8; -fx-border-radius: 6px;"
"-fx-padding: 8px 8px 4px 8px; -fx-border-color: #c8c8c8; -fx-border-radius: 6px;"
+ " -fx-background-radius: 6px; -fx-background-color: #f9f9f9;");
Label title = new Label(displayName);
@@ -1708,14 +1859,22 @@ public final class GuiConfigurationEditorWorkspace {
fieldGrid.add(timeoutField, 3, gridRow);
gridRow++;
// Fehlerzeile für Basis-URL und/oder Timeout (gemeinsame Zeile)
// Fehlerzeile für Basis-URL und/oder Timeout (gemeinsame Zeile).
// Platzhalter in Spalte 0/2 werden auf managed=false gesetzt, damit die Zeile
// kollabiert wenn das eigentliche Fehler-Label in Spalte 1/3 ebenfalls unmanaged ist.
if (baseUrlError != null || timeoutError != null) {
if (baseUrlError != null) {
fieldGrid.add(new Label(), 0, gridRow);
Label spacerBaseUrl = new Label();
spacerBaseUrl.setManaged(false);
spacerBaseUrl.setVisible(false);
fieldGrid.add(spacerBaseUrl, 0, gridRow);
fieldGrid.add(baseUrlError, 1, gridRow);
}
if (timeoutError != null) {
fieldGrid.add(new Label(), 2, gridRow);
Label spacerTimeout = new Label();
spacerTimeout.setManaged(false);
spacerTimeout.setVisible(false);
fieldGrid.add(spacerTimeout, 2, gridRow);
fieldGrid.add(timeoutError, 3, gridRow);
}
gridRow++;
@@ -1726,6 +1885,7 @@ public final class GuiConfigurationEditorWorkspace {
pState.model(),
val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
pState2.baseUrl(), val, pState2.timeoutSeconds(), pState2.apiKey())));
modelContainer.applyTooltip(GuiTooltipTexts.PROVIDER_MODELL);
modelFieldContainers.put(family, modelContainer);
modelCatalogCoordinator.registerFieldContainer(family, modelContainer);
Label modelError = createFieldErrorLabel();
@@ -1736,7 +1896,11 @@ public final class GuiConfigurationEditorWorkspace {
GridPane.setColumnSpan(modelNode, 3);
gridRow++;
if (modelError != null) {
fieldGrid.add(new Label(), 0, gridRow);
// Platzhalter auf managed=false Zeile kollabiert wenn modelError nicht sichtbar ist.
Label spacerModel = new Label();
spacerModel.setManaged(false);
spacerModel.setVisible(false);
fieldGrid.add(spacerModel, 0, gridRow);
fieldGrid.add(modelError, 1, gridRow);
GridPane.setColumnSpan(modelError, 3);
gridRow++;
@@ -1758,12 +1922,20 @@ public final class GuiConfigurationEditorWorkspace {
GridPane.setColumnSpan(apiKeyBox, 3);
gridRow++;
if (apiKeyError != null) {
fieldGrid.add(new Label(), 0, gridRow);
// Platzhalter auf managed=false Zeile kollabiert wenn apiKeyError nicht sichtbar ist.
Label spacerApiKey = new Label();
spacerApiKey.setManaged(false);
spacerApiKey.setVisible(false);
fieldGrid.add(spacerApiKey, 0, gridRow);
fieldGrid.add(apiKeyError, 1, gridRow);
GridPane.setColumnSpan(apiKeyError, 3);
gridRow++;
}
fieldGrid.add(new Label(), 0, gridRow);
// Platzhalter auf managed=false Zeile kollabiert wenn apiKeyOriginLabel nicht sichtbar ist.
Label spacerOrigin = new Label();
spacerOrigin.setManaged(false);
spacerOrigin.setVisible(false);
fieldGrid.add(spacerOrigin, 0, gridRow);
fieldGrid.add(apiKeyOriginLabel, 1, gridRow);
GridPane.setColumnSpan(apiKeyOriginLabel, 3);
@@ -1807,12 +1979,14 @@ public final class GuiConfigurationEditorWorkspace {
TextField maxPagesField = boundTextField(
editorState.values().maxPages(),
val -> updateValues(editorState.values().withMaxPages(val)));
applyTooltip(maxPagesField, GuiTooltipTexts.LIMITS_MAX_PAGES);
grid.add(new Label("Max. Seiten:"), 0, row);
grid.add(maxPagesField, 1, row);
TextField maxCharsField = boundTextField(
editorState.values().maxTextCharacters(),
val -> updateValues(editorState.values().withMaxTextCharacters(val)));
applyTooltip(maxCharsField, GuiTooltipTexts.LIMITS_MAX_TEXT_CHARACTERS);
grid.add(new Label("Max. Zeichen:"), 2, row);
grid.add(maxCharsField, 3, row);
row++;
@@ -1821,6 +1995,7 @@ public final class GuiConfigurationEditorWorkspace {
TextField maxTitleLengthField = boundTextField(
editorState.values().maxTitleLength(),
val -> updateValues(editorState.values().withMaxTitleLength(val)));
applyTooltip(maxTitleLengthField, GuiTooltipTexts.LIMITS_MAX_TITLE_LENGTH);
grid.add(new Label("Max. Titellänge:"), 0, row);
grid.add(maxTitleLengthField, 1, row);
@@ -1875,9 +2050,11 @@ public final class GuiConfigurationEditorWorkspace {
validateButton.setId("validate-button");
validateButton.setOnAction(e -> runValidationAction());
applyTooltip(validateButton, GuiTooltipTexts.TOOLBAR_VALIDIEREN);
technicalTestsButton.setId("technical-tests-button");
technicalTestsButton.setOnAction(e -> runTechnicalTestsAction());
applyTooltip(technicalTestsButton, GuiTooltipTexts.TOOLBAR_TECHNISCHE_TESTS);
HBox buttonRow = new HBox(8, validateButton, technicalTestsButton);
buttonRow.setAlignment(Pos.CENTER_LEFT);
@@ -1901,7 +2078,8 @@ public final class GuiConfigurationEditorWorkspace {
*/
private void runTechnicalTestsAction() {
LOG.info("Aktion Technische Tests ausführen gestartet.");
pendingMessages.clear();
// Hinweis: pendingMessages.clear() erfolgt jetzt im Coordinator selbst,
// damit clear() und Worker-Start auf demselben Thread (FX) liegen.
technicalTestsButton.setDisable(true);
technicalTestCoordinator.triggerTechnicalTests();
}
@@ -1924,7 +2102,8 @@ public final class GuiConfigurationEditorWorkspace {
messagesListView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
messagesListView.setMinHeight(60);
messagesListView.setPrefHeight(60);
messagesListView.setMaxHeight(200);
// Kein MaxHeight-Limit ListView dehnt sich vertikal aus wenn Platz vorhanden ist.
VBox.setVgrow(messagesListView, Priority.ALWAYS);
messagesListView.setStyle("-fx-border-color: #d8d8d8;");
Label placeholder = new Label("Keine Meldungen vorhanden.");
placeholder.setStyle("-fx-text-fill: #888888;");
@@ -1976,6 +2155,8 @@ public final class GuiConfigurationEditorWorkspace {
// Populate immediately so the area is not blank before the first validation run.
refreshMessagesArea();
// Meldungsbereich füllt den verbleibenden vertikalen Platz in sectionsBox.
VBox.setVgrow(card, Priority.ALWAYS);
return card;
}
@@ -2434,20 +2615,21 @@ public final class GuiConfigurationEditorWorkspace {
}
/**
* Opens a file-picker dialog using the injectable {@link #filePickerDialog} hook.
* Oeffnet einen Datei-Auswaehldialog ueber den injizierbaren {@link #filePickerDialog}-Hook.
* <p>
* In production the hook delegates to a native {@link FileChooser}. In tests the hook
* can be replaced with a lambda that returns a fixed string. Windows mapped drive letters
* are preserved unchanged.
* In der Produktion delegiert der Hook an einen nativen {@link FileChooser} und wendet
* die uebergebenen Filter an. In Tests kann der Hook durch eine Lambda-Implementierung
* ersetzt werden, die einen festen Pfad zurueckgibt. Windows-Laufwerksbuchstaben
* (z.&nbsp;B. {@code S:\}) werden unveraendert weitergegeben.
*
* @param title the dialog title
* @param initialPath the pre-selected path text; may be empty or {@code null}
* @param filters extension filters (only applied by the native default implementation)
* @return the selected absolute path string, or {@code null} when the dialog was cancelled
* @param title der Titel des Datei-Dialogs
* @param initialPath der Anfangspfad als Hinweis; darf leer oder {@code null} sein
* @param filters Dateitypfilter, die im nativen Dialog angezeigt werden
* @return den ausgewaehlten absoluten Pfad, oder {@code null} wenn der Dialog abgebrochen wurde
*/
private String pickFile(String title, String initialPath,
FileChooser.ExtensionFilter... filters) {
return filePickerDialog.apply(title, initialPath);
return filePickerDialog.pick(title, initialPath, List.of(filters));
}
/**
@@ -2472,22 +2654,30 @@ public final class GuiConfigurationEditorWorkspace {
}
/**
* Default native file-chooser implementation used by {@link #filePickerDialog}.
* Standardimplementierung des nativen Datei-Auswaehldialogs fuer {@link #filePickerDialog}.
* <p>
* Wendet alle uebergebenen Dateitypfilter auf den {@link FileChooser} an, bevor der Dialog
* geoeffnet wird.
*
* @param title the dialog title
* @param initialPath the initial path hint; may be empty or {@code null}
* @return the selected absolute path string, or {@code null} when cancelled or unavailable
* @param title der Titel des Datei-Dialogs
* @param initialPath der Anfangspfad als Hinweis; darf leer oder {@code null} sein
* @param filters anzuwendende Dateitypfilter; darf leer, aber nicht {@code null} sein
* @return den ausgewaehlten absoluten Pfad, oder {@code null} wenn abgebrochen oder nicht verfuegbar
*/
private String showNativeFileChooser(String title, String initialPath) {
private String showNativeFileChooser(String title, String initialPath,
List<FileChooser.ExtensionFilter> filters) {
FileChooser chooser = new FileChooser();
chooser.setTitle(title);
setInitialPathForFileChooser(chooser, initialPath);
if (!filters.isEmpty()) {
chooser.getExtensionFilters().addAll(filters);
}
Window owner = root.getScene() == null ? null : root.getScene().getWindow();
try {
File selected = chooser.showOpenDialog(owner);
return selected == null ? null : selected.getAbsolutePath();
} catch (UnsupportedOperationException e) {
LOG.debug("GUI-Editor: Datei-Dialog nicht verf\u00fcgbar (headless).");
LOG.debug("GUI-Editor: Datei-Dialog nicht verfuegbar (headless).");
return null;
}
}
@@ -2581,50 +2771,6 @@ public final class GuiConfigurationEditorWorkspace {
return label;
}
/**
* Builds a labelled path-field row as a {@link VBox} containing a grid row with the field
* and picker button, plus an optional error-slot label directly beneath.
* <p>
* The method uses an 8-pixel top padding to separate consecutive field rows visually, matching
* the former grid vgap behaviour.
*
* @param labelText the row label text; must not be {@code null}
* @param field the text field; must not be {@code null}
* @param errorLabel the field-level error label, or {@code null} for no error slot
* @param onPick action invoked when the picker button is clicked; must not be {@code null}
* @return a VBox containing the labelled row and optional error slot
*/
private static VBox buildPathFieldRow(String labelText, TextField field,
Label errorLabel, Runnable onPick) {
Label label = new Label(labelText);
Button pickButton = new Button("");
pickButton.setOnAction(e -> onPick.run());
pickButton.setMinWidth(32);
HBox fieldBox = new HBox(4, field, pickButton);
HBox.setHgrow(field, Priority.ALWAYS);
fieldBox.setAlignment(Pos.CENTER_LEFT);
GridPane grid = new GridPane();
grid.setHgap(12);
grid.setVgap(0);
javafx.scene.layout.ColumnConstraints labelCol = new javafx.scene.layout.ColumnConstraints();
labelCol.setMinWidth(180);
labelCol.setPrefWidth(200);
javafx.scene.layout.ColumnConstraints fieldCol = new javafx.scene.layout.ColumnConstraints();
fieldCol.setFillWidth(true);
fieldCol.setHgrow(Priority.ALWAYS);
grid.getColumnConstraints().addAll(labelCol, fieldCol);
grid.add(label, 0, 0);
grid.add(fieldBox, 1, 0);
grid.setPadding(new Insets(4, 0, 0, 0));
VBox slot = new VBox(0, grid);
if (errorLabel != null) {
slot.getChildren().add(errorLabel);
}
return slot;
}
/**
* Baut zwei Pfad-Felder nebeneinander in einer HBox. Jedes Feld hat ein Label und einen Picker-Button.
* Error-Labels werden untereinander angezeigt.
@@ -2759,19 +2905,6 @@ public final class GuiConfigurationEditorWorkspace {
return field;
}
/**
* Adds a label and a text field (without a picker button) to the given grid row.
*
* @param grid the target grid pane
* @param row the grid row index
* @param labelText the row label text
* @param field the text field to place
*/
private static void addSimpleRow(GridPane grid, int row, String labelText, TextField field) {
grid.add(new Label(labelText), 0, row);
grid.add(field, 1, row);
}
/**
* Erstellt ein GridPane für Konfigurationsfelder mit kompaktem vertikalen Abstand.
*/
@@ -2843,6 +2976,22 @@ public final class GuiConfigurationEditorWorkspace {
: exception.getMessage();
}
/**
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf das angegebene Control.
* <p>
* Alle Tooltips in dieser Klasse werden über diese Methode gesetzt, damit ein konsistentes
* Erscheinungsbild gewährleistet ist. Darf nur auf dem JavaFX Application Thread aufgerufen werden.
*
* @param control der Button oder ein anderes {@link javafx.scene.control.Control}, das den
* Tooltip erhalten soll; darf nicht {@code null} sein
* @param text der Tooltip-Text; darf nicht leer sein
*/
private static void applyTooltip(javafx.scene.control.Control control, String text) {
Tooltip tooltip = new Tooltip(text);
tooltip.setShowDelay(Duration.millis(300));
control.setTooltip(tooltip);
}
/**
* Speichert den Pfad einer gerade geladenen Konfigurationsdatei.
* Der Pfad wird in den Java Preferences gespeichert und beim nächsten Start
@@ -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>
@@ -62,6 +70,14 @@ public final class GuiModelCatalogCoordinator {
private final Map<AiProviderFamily, GuiModelFieldContainer> fieldContainers =
new ConcurrentHashMap<>();
/**
* Generationszähler zur Erkennung veralteter Abruf-Ergebnisse.
* Wird bei jeder neuen Anfrage in {@link #triggerModelRetrieval} atomar erhöht.
* Hintergrund-Threads erfassen die Generation beim Start; auf dem JavaFX-Thread wird
* das Ergebnis verworfen, wenn die gespeicherte Generation nicht mehr aktuell ist.
*/
private final AtomicLong retrievalGeneration = new AtomicLong(0);
/**
* Consumer that delivers the retrieval result. In production this wraps the call in
* {@code Platform.runLater}. In tests it can be replaced with a direct call so the result
@@ -144,12 +160,23 @@ public final class GuiModelCatalogCoordinator {
// Build the request from the current editor state.
ModelCatalogRequest request = buildRequest(family, providerState);
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet.",
family.getIdentifier());
// Generationsnummer erhöhen laufende Hintergrund-Threads mit einer älteren
// Generationsnummer verwerfen ihr Ergebnis, sobald sie auf dem FX-Thread ankommen.
long currentGeneration = retrievalGeneration.incrementAndGet();
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet (Generation {}).",
family.getIdentifier(), currentGeneration);
Runnable task = () -> {
ModelCatalogResult result = modelCatalogPort.fetchAvailableModels(request);
resultDelivery.accept(() -> {
// Veraltetes Ergebnis verwerfen, wenn inzwischen eine neuere Anfrage gestartet wurde.
if (retrievalGeneration.get() != currentGeneration) {
LOG.debug("GUI-Modellabruf: Ergebnis für Provider '{}' verworfen"
+ " (Generation {} ist nicht mehr aktuell).",
family.getIdentifier(), currentGeneration);
return;
}
applyResult(family, container, result, previousManualValue);
postResultCallback.run();
});
@@ -0,0 +1,64 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
/**
* GUI-internes Bridge-Interface zwischen dem Prompt-Editor-Tab und dem zugehörigen
* Use-Case in der Application-Schicht.
* <p>
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Es ist eine modul-interne Brücke, über die Bootstrap die vom Use-Case bereitgestellte
* Funktionalität in den GUI-Adapter einschleust, ohne dass der GUI-Adapter direkt auf
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PromptPort} oder das Dateisystem
* zugreift.
* <p>
* <strong>Verantwortung:</strong>
* <ul>
* <li>Prompt-Inhalt für die Anzeige im Editor laden.</li>
* <li>Bearbeiteten Inhalt atomar speichern.</li>
* <li>Standard-Prompt-Datei anlegen, wenn noch keine vorhanden ist.</li>
* </ul>
* <p>
* Alle Implementierungen dieses Interfaces liegen in {@code pdf-umbenenner-bootstrap}.
* Das GUI-Modul kennt ausschließlich den Interface-Typ.
*/
public interface GuiPromptEditorPort {
/**
* Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Quelle.
* <p>
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
*
* @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg,
* oder einem klassifizierten Fehler; nie {@code null}
*/
PromptLoadingResult loadCurrentPrompt();
/**
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
* <p>
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
*
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @throws NullPointerException wenn {@code content} null ist
*/
PromptSaveResult save(String content);
/**
* Legt eine Standard-Prompt-Datei an, falls noch keine vorhanden ist.
* <p>
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
*
* @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein
* @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null}
* @throws NullPointerException wenn {@code suggestion} null ist
*/
CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion);
}
@@ -0,0 +1,22 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
/**
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort} erzeugt.
* <p>
* Wird vom {@link GuiConfigurationEditorWorkspace} genutzt, um nach einem Konfigurations-Laden
* oder -Speichern einen neuen Port für den {@link GuiPromptEditorTab} zu erstellen, ohne dass
* der GUI-Adapter direkt von Bootstrap-internen Klassen abhängen muss.
* <p>
* Alle Implementierungen liegen in {@code pdf-umbenenner-bootstrap}.
*/
@FunctionalInterface
public interface GuiPromptEditorPortFactory {
/**
* Erzeugt einen {@link GuiPromptEditorPort} für den angegebenen Prompt-Dateipfad.
*
* @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
* @return vollständig verdrahteter Port; nie {@code null}
*/
GuiPromptEditorPort create(String promptFilePath);
}
@@ -0,0 +1,413 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TextArea;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.scene.text.Font;
/**
* Tab Prompt" im Hauptfenster des GUI-Adapters.
* <p>
* Ermöglicht das Lesen, Bearbeiten und Speichern der konfigurierten KI-Prompt-Datei
* direkt aus der Oberfläche heraus, ohne einen externen Editor öffnen zu müssen.
* <p>
* <strong>Verhalten:</strong>
* <ul>
* <li>Beim Öffnen des Tabs wird der aktuelle Prompt-Inhalt auf einem Worker-Thread geladen.</li>
* <li>Bearbeitungen erzeugen einen Dirty-State; der Tab-Titel erhält einen Asterisk.</li>
* <li>Speichern" schreibt den Inhalt atomar via {@link GuiPromptEditorPort}.</li>
* <li>Auf Standard zurücksetzen" befüllt die TextArea mit dem Default-Template,
* ohne zu speichern.</li>
* <li>Bei fehlendem Prompt wird ein Hinweis und ein Standard-Prompt erstellen"-Button
* angezeigt.</li>
* <li>Tab-Wechsel oder Schließen mit Dirty-State löst einen Bestätigungsdialog aus.</li>
* </ul>
* <p>
* <strong>Threading:</strong> Alle blockierenden Operationen (Laden, Speichern,
* Prompt-Datei anlegen) laufen auf einem Worker-Thread. UI-Aktualisierungen erfolgen
* ausschließlich via {@code Platform.runLater}.
*/
public class GuiPromptEditorTab {
private static final Logger LOG = LogManager.getLogger(GuiPromptEditorTab.class);
private static final String TAB_TITLE = "Prompt";
private static final String TAB_TITLE_DIRTY = "Prompt *";
private GuiPromptEditorPort promptEditorPort;
/** Konfigurierter Prompt-Dateipfad wird für CreatePromptFile-Vorschläge benötigt. */
private String configuredPromptPath;
/** Konfigurierte maximale Titellänge für den Default-Prompt-Inhalt. */
private int maxTitleLength;
// Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
/** Erzeugt Worker-Threads für blockierende Operationen. */
Function<Runnable, Thread> threadFactory;
/** Übergibt UI-Updates an den JavaFX Application Thread. */
Consumer<Runnable> fxDispatcher;
private final Tab tab = new Tab(TAB_TITLE);
private final TextArea textArea = new TextArea();
private final Label statusLabel = new Label();
private final Button saveButton = new Button("Speichern");
private final Button resetButton = new Button("Auf Standard zurücksetzen");
private final Button createDefaultButton = new Button("Standard-Prompt erstellen");
/** Zeigt an, ob der aktuelle Inhalt der TextArea vom geladenen Stand abweicht. */
private boolean dirty = false;
/** Zuletzt aus der Datei geladener Inhalt (Baseline). */
private String loadedContent = null;
/**
* Erstellt den Prompt-Editor-Tab.
*
* @param promptEditorPort Bridge-Port zum Use-Case; darf nicht {@code null} sein
* @param configuredPromptPath konfigurierter Pfad zur Prompt-Datei (für CreatePromptFile);
* darf nicht {@code null} sein
* @param maxTitleLength konfigurierte maximale Titellänge für den Default-Prompt
* @throws NullPointerException wenn {@code promptEditorPort} oder {@code configuredPromptPath} null ist
*/
public GuiPromptEditorTab(GuiPromptEditorPort promptEditorPort,
String configuredPromptPath,
int maxTitleLength) {
this.promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
this.configuredPromptPath = Objects.requireNonNull(configuredPromptPath, "configuredPromptPath must not be null");
this.maxTitleLength = maxTitleLength;
// Standard-Implementierungen für den Produktionsbetrieb
this.threadFactory = runnable -> {
Thread t = new Thread(runnable, "gui-prompt-editor");
t.setDaemon(true);
return t;
};
this.fxDispatcher = Platform::runLater;
buildTab();
}
/**
* Liefert das JavaFX-Tab-Objekt, das dem TabPane hinzugefügt werden kann.
*
* @return das Tab; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Gibt an, ob der Prompt-Editor ungespeicherte Änderungen enthält.
*
* @return {@code true}, wenn Dirty-State aktiv ist
*/
public boolean hasDirtyContent() {
return dirty;
}
/**
* Aktualisiert den Tab auf eine neue Konfiguration.
* <p>
* Setzt Port, Prompt-Dateipfad und maximale Titellänge auf die neuen Werte.
* Der bisherige Lade-Baseline wird verworfen und der Dirty-State zurückgesetzt.
* Ist der Tab zum Zeitpunkt des Aufrufs sichtbar, wird ein erneutes Laden sofort
* ausgelöst; andernfalls erfolgt das Laden beim nächsten Öffnen des Tabs.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param newPort neuer Port für Prompt-Operationen; darf nicht {@code null} sein
* @param newPromptPath neuer konfigurierter Prompt-Dateipfad; darf nicht {@code null} sein
* @param newMaxTitleLength neue konfigurierte maximale Titellänge
*/
public void notifyConfigurationChanged(GuiPromptEditorPort newPort,
String newPromptPath,
int newMaxTitleLength) {
this.promptEditorPort = Objects.requireNonNull(newPort, "newPort must not be null");
this.configuredPromptPath = Objects.requireNonNull(newPromptPath, "newPromptPath must not be null");
this.maxTitleLength = newMaxTitleLength;
this.loadedContent = null;
this.dirty = false;
this.tab.setText(TAB_TITLE);
this.saveButton.setDisable(true);
if (tab.isSelected()) {
loadPromptAsync();
}
}
/**
* Verwirft alle ungespeicherten Änderungen und setzt den Tab in den Lade-Bereitschaftszustand.
* <p>
* Setzt Dirty-State und Tab-Titel zurück. Ist der Tab zum Zeitpunkt des Aufrufs sichtbar,
* wird der Prompt-Inhalt sofort neu geladen; andernfalls erfolgt das Laden beim nächsten
* Öffnen des Tabs (gesteuert durch den Tab-Selektions-Listener).
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void discardChanges() {
this.loadedContent = null;
this.dirty = false;
this.tab.setText(TAB_TITLE);
this.saveButton.setDisable(true);
if (tab.isSelected()) {
loadPromptAsync();
}
}
/**
* Zeigt einen Bestätigungsdialog, wenn ungespeicherte Änderungen vorhanden sind.
* Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen.
*
* @return {@code true} zum Verwerfen, {@code false} zum Abbrechen
*/
public boolean confirmDiscardIfDirty() {
if (!dirty) {
return true;
}
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
alert.setTitle("Ungespeicherte Änderungen");
alert.setHeaderText("Der Prompt-Editor enthält ungespeicherte Änderungen.");
alert.setContentText("Möchten Sie die Änderungen verwerfen?");
alert.getButtonTypes().setAll(
new ButtonType("Verwerfen"),
ButtonType.CANCEL);
Optional<ButtonType> result = alert.showAndWait();
return result.isPresent() && result.get().getText().equals("Verwerfen");
}
/**
* Lädt den aktuellen Prompt-Inhalt auf einem Worker-Thread und zeigt ihn in der TextArea an.
* <p>
* Muss vom JavaFX Application Thread aufgerufen werden. Die eigentliche I/O-Operation
* läuft auf einem Hintergrund-Thread; UI-Updates erfolgen via {@code fxDispatcher}.
*/
public void loadPromptAsync() {
setStatus("Lade Prompt-Datei ...");
saveButton.setDisable(true);
resetButton.setDisable(true);
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
Thread worker = threadFactory.apply(() -> {
var result = promptEditorPort.loadCurrentPrompt();
fxDispatcher.accept(() -> applyLoadResult(result));
});
worker.start();
}
// -------------------------------------------------------------------------
// Private Aufbau
// -------------------------------------------------------------------------
private void buildTab() {
tab.setClosable(false);
// TextArea monospace Font für bessere Lesbarkeit
textArea.setWrapText(true);
textArea.setFont(Font.font("Monospace", 13));
textArea.setPrefRowCount(20);
VBox.setVgrow(textArea, Priority.ALWAYS);
// Dirty-State-Tracking
textArea.textProperty().addListener((obs, oldVal, newVal) -> {
if (loadedContent != null) {
boolean nowDirty = !newVal.equals(loadedContent);
if (nowDirty != dirty) {
dirty = nowDirty;
tab.setText(dirty ? TAB_TITLE_DIRTY : TAB_TITLE);
}
}
});
// Status-Label
statusLabel.setWrapText(true);
statusLabel.setStyle("-fx-text-fill: #555555;");
// Buttons verdrahten
saveButton.setTooltip(new Tooltip("Prompt-Datei speichern (atomar, UTF-8)."));
saveButton.setOnAction(e -> requestSave());
resetButton.setTooltip(new Tooltip("Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern."));
resetButton.setOnAction(e -> resetToDefault());
createDefaultButton.setTooltip(new Tooltip(
"Standard-Prompt-Datei am konfigurierten Pfad anlegen."));
createDefaultButton.setOnAction(e -> requestCreateDefault());
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
HBox buttonBar = new HBox(8, saveButton, resetButton, createDefaultButton);
buttonBar.setAlignment(Pos.CENTER_LEFT);
buttonBar.setPadding(new Insets(6, 0, 0, 0));
VBox content = new VBox(6, textArea, statusLabel, buttonBar);
content.setPadding(new Insets(12));
VBox.setVgrow(textArea, Priority.ALWAYS);
BorderPane root = new BorderPane(content);
tab.setContent(root);
// Beim Öffnen des Tabs laden (falls Konfiguration bereits vorhanden)
tab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
if (Boolean.TRUE.equals(isSelected) && loadedContent == null) {
loadPromptAsync();
}
});
}
private void applyLoadResult(de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult result) {
if (result instanceof PromptLoadingSuccess success) {
loadedContent = success.promptContent();
textArea.setText(loadedContent);
textArea.setEditable(true);
saveButton.setDisable(false);
resetButton.setDisable(false);
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
setStatus("Prompt-Datei geladen. Identifikator: " + success.promptIdentifier().identifier());
dirty = false;
tab.setText(TAB_TITLE);
LOG.info("Prompt-Editor: Prompt-Datei erfolgreich geladen (Identifikator: {}).",
success.promptIdentifier().identifier());
} else if (result instanceof PromptLoadingFailure failure) {
boolean fileNotFound = "FILE_NOT_FOUND".equals(failure.failureReason());
if (fileNotFound) {
// Datei fehlt Hinweis und Anlegen-Button anzeigen
loadedContent = null;
textArea.setEditable(false);
textArea.clear();
saveButton.setDisable(true);
resetButton.setDisable(false);
createDefaultButton.setVisible(true);
createDefaultButton.setManaged(true);
setStatus("Keine Prompt-Datei vorhanden. Legen Sie eine Standard-Datei an oder "
+ "konfigurieren Sie den Pfad im Konfigurationstab.");
LOG.info("Prompt-Editor: Keine Prompt-Datei am konfigurierten Pfad vorhanden.");
} else {
// Anderer Fehler (I/O, leer usw.)
loadedContent = null;
textArea.setEditable(false);
textArea.clear();
saveButton.setDisable(true);
resetButton.setDisable(false);
createDefaultButton.setVisible(false);
createDefaultButton.setManaged(false);
setStatus("Fehler beim Laden der Prompt-Datei: " + failure.failureMessage());
LOG.warn("Prompt-Editor: Laden fehlgeschlagen ({}): {}",
failure.failureReason(), failure.failureMessage());
}
}
}
private void requestSave() {
String currentText = textArea.getText();
// Leerer Prompt: Bestätigungsdialog
if (currentText.trim().isEmpty()) {
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
confirm.setTitle("Leerer Prompt");
confirm.setHeaderText("Der Prompt ist leer.");
confirm.setContentText("Wirklich eine leere Prompt-Datei speichern?");
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
Optional<ButtonType> choice = confirm.showAndWait();
if (choice.isEmpty() || choice.get() != ButtonType.OK) {
return;
}
}
setStatus("Speichere ...");
saveButton.setDisable(true);
Thread worker = threadFactory.apply(() -> {
PromptSaveResult result = promptEditorPort.save(currentText);
fxDispatcher.accept(() -> applySaveResult(result, currentText));
});
worker.start();
}
private void applySaveResult(PromptSaveResult result, String savedContent) {
saveButton.setDisable(false);
if (result instanceof PromptSaveResult.Saved saved) {
loadedContent = savedContent;
dirty = false;
tab.setText(TAB_TITLE);
setStatus("Prompt-Datei gespeichert: " + saved.absolutePath());
textArea.setEditable(true);
LOG.info("Prompt-Editor: Prompt-Datei gespeichert unter {}.", saved.absolutePath());
} else if (result instanceof PromptSaveResult.TargetDirectoryMissing missing) {
setStatus("Fehler: " + missing.message());
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen Ordner fehlt: {}", missing.message());
} else if (result instanceof PromptSaveResult.WriteFailed failed) {
setStatus("Fehler beim Schreiben: " + failed.message());
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen Schreibfehler: {}", failed.message());
} else if (result instanceof PromptSaveResult.AtomicMoveFailed atomicFailed) {
setStatus("Fehler: Atomares Speichern fehlgeschlagen (kein Fallback). " + atomicFailed.message());
LOG.warn("Prompt-Editor: Atomares Verschieben fehlgeschlagen: {}", atomicFailed.message());
}
}
void resetToDefault() {
String defaultContent = de.gecheckt.pdf.umbenenner.application.validation
.technicaltest.DefaultPromptTemplate.defaultContent(maxTitleLength);
textArea.setText(defaultContent);
textArea.setEditable(true);
saveButton.setDisable(false);
setStatus("Standard-Prompt-Inhalt in den Editor geladen (noch nicht gespeichert).");
LOG.info("Prompt-Editor: Standard-Prompt-Inhalt in TextArea geladen (nicht gespeichert).");
}
private void requestCreateDefault() {
createDefaultButton.setDisable(true);
setStatus("Lege Standard-Prompt-Datei an ...");
CorrectionSuggestion.CreatePromptFile suggestion = new CorrectionSuggestion.CreatePromptFile(
configuredPromptPath,
"Standard-Prompt-Datei anlegen",
maxTitleLength);
Thread worker = threadFactory.apply(() -> {
CorrectionOutcome outcome = promptEditorPort.createDefaultPromptIfMissing(suggestion);
fxDispatcher.accept(() -> applyCreateDefaultResult(outcome));
});
worker.start();
}
private void applyCreateDefaultResult(CorrectionOutcome outcome) {
createDefaultButton.setDisable(false);
if (outcome instanceof CorrectionOutcome.Applied applied) {
setStatus(applied.message() + " Lade Inhalt ...");
LOG.info("Prompt-Editor: Standard-Prompt-Datei angelegt. Lade neu.");
// Inhalt sofort neu laden
loadPromptAsync();
} else if (outcome instanceof CorrectionOutcome.Failed failed) {
setStatus("Fehler beim Anlegen der Standard-Prompt-Datei: " + failed.errorMessage());
LOG.warn("Prompt-Editor: Anlegen der Standard-Prompt-Datei fehlgeschlagen: {}", failed.errorMessage());
} else if (outcome instanceof CorrectionOutcome.NotAttempted notAttempted) {
setStatus("Aktion nicht verfügbar: " + notAttempted.reason());
LOG.warn("Prompt-Editor: Anlegen nicht versucht: {}", notAttempted.reason());
}
}
private void setStatus(String message) {
statusLabel.setText(message);
}
}
@@ -11,10 +11,12 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
@@ -46,7 +48,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
* folder for documents that have not yet been successfully processed, and
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
* context for documents that were skipped in the current run.
* context for documents that were skipped in the current run, and the resolved application
* version string that the status bar displays at the bottom of the main window.
* <p>
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
* know about provider-specific HTTP details or adapter wiring.
@@ -67,7 +70,14 @@ public record GuiStartupContext(
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
GuiPromptEditorPort promptEditorPort,
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory) {
/**
* Creates a fully wired startup context.
@@ -94,6 +104,12 @@ public record GuiStartupContext(
* must not be {@code null}
* @param historicalDocumentContextPort bridge that resolves the historical processing context
* for skipped documents; must not be {@code null}
* @param applicationVersion resolved application version string shown in the status
* bar; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
* {@code null} sein
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
* darf nicht {@code null} sein
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -126,6 +142,19 @@ public record GuiStartupContext(
"manualFileCopyPort must not be null");
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
"historicalDocumentContextPort must not be null");
// Null-Fallback für Testumgebungen ohne gepacktes JAR
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
"historyOverviewPort must not be null");
historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
"historyDetailsPort must not be null");
historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
"historyResetDocumentStatusPort must not be null");
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
"deleteDocumentHistoryPort must not be null");
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
"promptEditorPortFactory must not be null");
}
/**
@@ -167,7 +196,9 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
}
/**
@@ -203,7 +234,9 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
}
/**
@@ -239,7 +272,9 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -309,7 +344,8 @@ public record GuiStartupContext(
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
noOpPathCheckPort,
noOpTestService);
noOpTestService,
() -> java.util.Optional.empty());
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
@@ -353,6 +389,64 @@ public record GuiStartupContext(
rejectingResetPort(),
rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
noOpHistoricalDocumentContextPort(),
"dev",
noOpPromptEditorPort(),
noOpHistoryOverviewPort(),
noOpHistoryDetailsPort(),
noOpHistoryResetPort(),
noOpDeleteHistoryPort(),
noOpPromptEditorPortFactory());
}
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
return path -> noOpPromptEditorPort();
}
private static GuiPromptEditorPort noOpPromptEditorPort() {
return new GuiPromptEditorPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
"NO_OP", "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
"Kein Prompt-Editor-Port in diesem Startkontext verfügbar.", null);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDefaultPromptIfMissing(
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(
suggestion, "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
}
};
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort
noOpHistoryOverviewPort() {
return (configFilePath, query) -> new de.gecheckt.pdf.umbenenner.application.usecase
.DefaultHistoryOverviewUseCase.HistoryOverviewResult(java.util.List.of(), false);
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort
noOpHistoryDetailsPort() {
return (configFilePath, fingerprint) -> java.util.Optional.empty();
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort
noOpHistoryResetPort() {
return (configFilePath, fingerprint) -> { /* kein Reset in diesem Startkontext */ };
}
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort
noOpDeleteHistoryPort() {
return (configFilePath, fingerprint) -> { /* kein Löschen in diesem Startkontext */ };
}
}
@@ -0,0 +1,196 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.Separator;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
/**
* Permanente Statuszeile am unteren Rand des Hauptfensters.
* <p>
* Die Statuszeile zeigt immer drei Segmente:
* <ul>
* <li><b>Links:</b> Anwendungsversion im Format {@code V<version>}, z.&nbsp;B. {@code Vdev}.</li>
* <li><b>Mitte:</b> Aktiver Provider und Modellname aus der geladenen Konfiguration.</li>
* <li><b>Rechts:</b> Pfad der geladenen Konfigurationsdatei.</li>
* </ul>
* Wenn keine Konfiguration geladen ist, zeigen Mitte und Rechts den Text
* {@value #KEIN_PROFIL_TEXT}. Die Versionsanzeige ist stets sichtbar.
* <p>
* Alle Aktualisierungen dieser Komponente müssen auf dem JavaFX Application Thread erfolgen.
* Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung.
*/
public final class GuiStatusBar {
/** Anzeigetext wenn keine Konfiguration geladen ist. */
static final String KEIN_PROFIL_TEXT = "Kein Profil geladen";
/** Präfix vor der Versionsnummer in der linken Statuszeilen-Zelle. */
private static final String VERSION_PREFIX = "V";
private static final AiProviderFamilyStringConverter PROVIDER_CONVERTER =
new AiProviderFamilyStringConverter();
private final String applicationVersion;
private final BorderPane root;
private final Label versionLabel;
private final Label providerLabel;
private final Label configPathLabel;
/**
* Erstellt eine neue Statuszeile mit der angegebenen Anwendungsversion.
*
* @param applicationVersion die aufgelöste Versionsnummer; {@code null} oder leer führt zum
* Fallback {@code "dev"}
*/
public GuiStatusBar(String applicationVersion) {
this.applicationVersion = (applicationVersion == null || applicationVersion.isBlank())
? "dev"
: applicationVersion;
// Linkes Segment: Versionsanzeige
this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion);
this.versionLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
// Mittleres Segment: Provider und Modell
this.providerLabel = new Label(KEIN_PROFIL_TEXT);
this.providerLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
this.providerLabel.setAlignment(Pos.CENTER);
// Rechtes Segment: Konfigurationspfad
this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
this.configPathLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
this.configPathLabel.setAlignment(Pos.CENTER_RIGHT);
// Abstandhalter zwischen den Segmenten
Region leftSpacer = new Region();
Region rightSpacer = new Region();
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
HBox content = new HBox(16,
versionLabel, leftSpacer,
providerLabel, rightSpacer,
configPathLabel);
content.setAlignment(Pos.CENTER_LEFT);
content.setPadding(new Insets(4, 12, 4, 12));
content.setStyle("-fx-background-color: #f5f5f5;");
Separator topSeparator = new Separator();
this.root = new BorderPane();
this.root.setTop(topSeparator);
this.root.setCenter(content);
}
/**
* Gibt den Wurzelknoten der Statuszeile zurück, der in das Hauptfenster eingebettet wird.
*
* @return der Wurzelknoten; nie {@code null}
*/
public BorderPane root() {
return root;
}
/**
* Aktualisiert die Statuszeile anhand des aktuellen Editor-Zustands.
* <p>
* Ist kein Dateisnapshot vorhanden, wird {@link #clearConfiguration()} ausgeführt.
* Andernfalls werden Provider, Modell und Konfigurationspfad aus dem Zustand ermittelt
* und angezeigt.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param state der aktuelle Editor-Zustand; darf nicht {@code null} sein
*/
public void applyEditorState(GuiConfigurationEditorState state) {
if (state == null || !state.hasLoadedFileSnapshot()) {
clearConfiguration();
return;
}
String configPath = state.configurationPathText();
String providerText = resolveProviderText(state);
providerLabel.setText(providerText);
configPathLabel.setText(configPath.isBlank() ? KEIN_PROFIL_TEXT : configPath);
}
/**
* Setzt Mitte und Rechts der Statuszeile auf den Text {@link #KEIN_PROFIL_TEXT} zurück.
* <p>
* Die Versionsanzeige bleibt unverändert.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clearConfiguration() {
providerLabel.setText(KEIN_PROFIL_TEXT);
configPathLabel.setText(KEIN_PROFIL_TEXT);
}
/**
* Gibt den aktuell angezeigten Versionstext zurück (inkl. Präfix {@code V}).
* <p>
* Für Tests zugänglich.
*
* @return der angezeigte Versionstext; nie {@code null}
*/
String versionText() {
return versionLabel.getText();
}
/**
* Gibt den aktuell angezeigten Provider-Text zurück.
* <p>
* Für Tests zugänglich.
*
* @return der angezeigte Provider-Text; nie {@code null}
*/
String providerText() {
return providerLabel.getText();
}
/**
* Gibt den aktuell angezeigten Konfigurationspfad-Text zurück.
* <p>
* Für Tests zugänglich.
*
* @return der angezeigte Konfigurationspfad-Text; nie {@code null}
*/
String configPathText() {
return configPathLabel.getText();
}
/**
* Ermittelt den anzuzeigenden Provider-Text aus dem Editor-Zustand.
* <p>
* Das Format ist: {@code Provider: <AnzeigeName> · <Modellname>}, wobei der Modellname
* weggelassen wird, wenn er leer ist.
*
* @param state der Editor-Zustand; darf nicht {@code null} sein
* @return der formatierte Provider-Text; nie {@code null}
*/
private static String resolveProviderText(GuiConfigurationEditorState state) {
String activeIdentifier = state.values().activeProviderFamily();
if (activeIdentifier == null || activeIdentifier.isBlank()) {
return KEIN_PROFIL_TEXT;
}
AiProviderFamily family = AiProviderFamily.fromIdentifier(activeIdentifier).orElse(null);
if (family == null) {
return KEIN_PROFIL_TEXT;
}
String displayName = PROVIDER_CONVERTER.toString(family);
GuiProviderConfigurationState providerState = state.values().providerConfiguration(family);
String model = providerState != null ? providerState.model() : "";
if (model == null || model.isBlank()) {
return "Provider: " + displayName;
}
return "Provider: " + displayName + " · " + model;
}
}
@@ -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,112 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
/**
* Zentrale Konstantenklasse für alle Tooltip-Texte der GUI.
* <p>
* Diese Klasse ist die einzige autoritative Quelle für Tooltip-Beschriftungen aller
* interaktiven Elemente in der Desktop-Oberfläche. Alle Tooltip-Strings werden hier
* definiert und von den jeweiligen UI-Klassen referenziert. Streustrings im
* UI-Code sind unzulässig.
* <p>
* Tooltip-Texte für Status-Icons werden <em>nicht</em> hier gepflegt sie stammen
* ausschließlich aus {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation},
* die die autoritative Quelle für alle statusbezogenen Darstellungsinformationen ist.
* <p>
* Alle Texte sind deutschsprachig gemäß Spezifikation.
* Diese Klasse enthält keine JavaFX-Typen und ist nicht instanziierbar.
*/
public final class GuiTooltipTexts {
// -------------------------------------------------------------------------
// Toolbar-Buttons
// -------------------------------------------------------------------------
/** Tooltip für den Button „Neu". */
public static final String TOOLBAR_NEU =
"Neue Konfiguration erstellen.";
/** Tooltip für den Button „Öffnen". */
public static final String TOOLBAR_OEFFNEN =
"Bestehende Konfigurationsdatei (.properties) öffnen.";
/** Tooltip für den Button „Speichern". */
public static final String TOOLBAR_SPEICHERN =
"Aktuelle Konfiguration speichern.";
/** Tooltip für den Button „Speichern unter". */
public static final String TOOLBAR_SPEICHERN_UNTER =
"Konfiguration unter neuem Dateipfad speichern.";
/** Tooltip für den Button „Validieren". */
public static final String TOOLBAR_VALIDIEREN =
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.";
/** Tooltip für den Button „Technische Tests ausführen". */
public static final String TOOLBAR_TECHNISCHE_TESTS =
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.";
// -------------------------------------------------------------------------
// Konfigurationstab Pfade
// -------------------------------------------------------------------------
/** Tooltip für das Eingabefeld „Quellordner". */
public static final String PFADE_QUELLORDNER =
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.";
/** Tooltip für das Eingabefeld „Zielordner". */
public static final String PFADE_ZIELORDNER =
"Ordner für die umbenannten Kopien.";
/** Tooltip für das Eingabefeld „SQLite-Datei". */
public static final String PFADE_SQLITE =
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.";
/** Tooltip für das Eingabefeld „Prompt-Datei". */
public static final String PFADE_PROMPT =
"Externe Textdatei mit den KI-Anweisungen.";
// -------------------------------------------------------------------------
// Konfigurationstab Provider
// -------------------------------------------------------------------------
/** Tooltip für die Provider-ComboBox. */
public static final String PROVIDER_COMBOBOX =
"Der KI-Dienst, der die Dateinamen generiert.";
/** Tooltip für das Modell-Eingabefeld (ComboBox oder manuelles TextField). */
public static final String PROVIDER_MODELL =
"Das konkrete Sprachmodell des gewählten Providers.";
// -------------------------------------------------------------------------
// Konfigurationstab Verarbeitungslimits
// -------------------------------------------------------------------------
/** Tooltip für das Eingabefeld „max.text.characters". */
public static final String LIMITS_MAX_TEXT_CHARACTERS =
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.";
/** Tooltip für das Eingabefeld „max.pages". */
public static final String LIMITS_MAX_PAGES =
"Maximale Seitenzahl, die aus einem PDF gelesen wird.";
/** Tooltip für das Eingabefeld „max.title.length". */
public static final String LIMITS_MAX_TITLE_LENGTH =
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10120.";
// -------------------------------------------------------------------------
// Verarbeitungslauf-Tab Dateiname-Editor
// -------------------------------------------------------------------------
/** Tooltip für den Button „Dateiname übernehmen". */
public static final String DATEINAME_UEBERNEHMEN =
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.";
/** Tooltip für den Button „Zurücksetzen auf KI-Vorschlag". */
public static final String DATEINAME_ZURUECKSETZEN =
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.";
/** Nicht instanziierbar reine Konstantenklasse. */
private GuiTooltipTexts() {
throw new UnsupportedOperationException("Nicht instanziierbar");
}
}
@@ -8,6 +8,7 @@ import javafx.application.Platform;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
@@ -69,7 +70,16 @@ public class PdfUmbenennerGuiApplication extends Application {
// Wire the title-update listener so the stage title stays in sync with the dirty state.
workspace.titleUpdateListener = primaryStage::setTitle;
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
// Statuszeile anlegen und mit dem Workspace verdrahten
GuiStatusBar statusBar = new GuiStatusBar(startupContext.applicationVersion());
workspace.statusBarStateListener = statusBar::applyEditorState;
// Statuszeile unterhalb des Workspace-Inhalts einbetten
BorderPane outerLayout = new BorderPane();
outerLayout.setCenter(workspace.root());
outerLayout.setBottom(statusBar.root());
Scene scene = new Scene(outerLayout, DEFAULT_WIDTH, DEFAULT_HEIGHT);
primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
primaryStage.setScene(scene);
@@ -0,0 +1,202 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
/**
* Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs
* die aggregierten Ergebnisse anzeigt.
*
* <p>Das Banner erscheint nach Laufabschluss unterhalb des Fortschrittsbalkens und
* oberhalb der Ergebnistabelle. Es zeigt nur Kategorien, deren Zähler größer als null
* ist. Folgende Status werden nicht gezählt und tauchen nie im Banner auf:
* {@code READY_FOR_AI}, {@code PROPOSAL_READY} und {@code PROCESSING} sind im
* Enum {@link DocumentCompletionStatus} nicht enthalten alle enthaltenen Werte
* werden gezählt, außer Einträgen mit {@code resetPending=true}, da diese keinen
* abgeschlossenen Zustand darstellen.
*
* <p>Farbe ist niemals das einzige Unterscheidungsmerkmal: Jedes Segment enthält
* ein Icon und einen Text.
*
* <p>Die öffentlichen Methoden {@link #clear()} und {@link #update(Map)} sind
* thread-agnostisch definiert, aber müssen auf dem JavaFX Application Thread aufgerufen
* werden (oder das Banner muss via {@code Platform.runLater} aktualisiert werden).
* Die Aggregations-Hilfsmethode {@link #aggregateCounts(Iterable)} ist vollständig
* unabhängig von JavaFX und kann auf jedem Thread aufgerufen werden.
*/
public final class BatchRunSummaryBanner {
/** Trennzeichen zwischen den Kategoriesegmenten. */
private static final String SEGMENT_SEPARATOR = " · ";
/** Abstand zwischen den Label-Segmenten in Pixeln. */
private static final int SPACING = 0;
/** Innerer Abstand des Containers in Pixeln (oben/unten). */
private static final double PADDING_V = 4.0;
/** Standardfarbe für den Summentext. */
private static final String STYLE_DEFAULT = "-fx-font-size: 12;";
/**
* Alle {@link DocumentCompletionStatus}-Werte, die im Banner angezeigt werden,
* in der verbindlichen Anzeigereihenfolge gemäß Spezifikation.
*/
private static final List<DocumentCompletionStatus> DISPLAYED_ORDER = List.of(
DocumentCompletionStatus.SUCCESS,
DocumentCompletionStatus.FAILED_RETRYABLE,
DocumentCompletionStatus.FAILED_PERMANENT,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE
);
/** Wurzel-Container des Banners wird in das Tab-Layout eingebettet. */
private final HBox container;
/** Label, das den kompletten Bannertext als Inline-Segmente trägt. */
private final Label contentLabel;
/**
* Erstellt ein neues, initial unsichtbares Summary-Banner.
*/
public BatchRunSummaryBanner() {
contentLabel = new Label();
contentLabel.setStyle(STYLE_DEFAULT);
contentLabel.setWrapText(false);
container = new HBox(SPACING, contentLabel);
container.setAlignment(Pos.CENTER_LEFT);
container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;");
// Initial unsichtbar, nimmt keinen Platz ein
container.setVisible(false);
container.setManaged(false);
}
// -------------------------------------------------------------------------
// Öffentliche API
// -------------------------------------------------------------------------
/**
* Versteckt das Banner und leert seinen Inhalt.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clear() {
contentLabel.setText("");
container.setVisible(false);
container.setManaged(false);
}
/**
* Aktualisiert das Banner mit den aggregierten Zählern und macht es sichtbar.
*
* <p>Zeigt nur Kategorien mit Anzahl &gt; 0. Wenn alle Zähler null sind (leerer Lauf),
* wird das Banner versteckt.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param counts Zuordnung von Verarbeitungsstatus zu Anzahl;
* fehlende Status werden als 0 interpretiert; darf nicht null sein
*/
public void update(Map<DocumentCompletionStatus, Integer> counts) {
Objects.requireNonNull(counts, "counts darf nicht null sein");
String text = buildBannerText(counts);
if (text.isEmpty()) {
clear();
return;
}
contentLabel.setText(text);
container.setVisible(true);
container.setManaged(true);
}
/**
* Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout.
*
* @return der Container-Knoten; nie null
*/
public HBox getNode() {
return container;
}
// -------------------------------------------------------------------------
// Aggregations-Hilfe (thread-agnostisch, testbar ohne JavaFX)
// -------------------------------------------------------------------------
/**
* Zählt die Anzahl jedes {@link DocumentCompletionStatus} in der übergebenen
* Iterable. Einträge mit {@code resetPending=true} werden ignoriert, da sie
* keinen abgeschlossenen Verarbeitungszustand darstellen.
*
* <p>Diese Methode ist vollständig unabhängig von JavaFX und kann auf jedem
* Thread aufgerufen werden.
*
* @param rows die Ergebniszeilen des Laufs; darf nicht null sein;
* null-Elemente werden übersprungen
* @return eine Map mit der Anzahl je Status; enthält alle anzuzeigenden
* Status (fehlende haben Wert 0); nie null
*/
public static Map<DocumentCompletionStatus, Integer> aggregateCounts(
Iterable<? extends GuiBatchRunResultRow> rows) {
Objects.requireNonNull(rows, "rows darf nicht null sein");
Map<DocumentCompletionStatus, Integer> counts = new EnumMap<>(DocumentCompletionStatus.class);
// Alle anzuzeigenden Status mit 0 vorbelegen
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
counts.put(status, 0);
}
for (GuiBatchRunResultRow row : rows) {
if (row == null) {
continue;
}
// Reset-Pending-Zeilen zählen nicht sie haben noch keinen abgeschlossenen Status
if (row.resetPending()) {
continue;
}
DocumentCompletionStatus status = row.status();
// Nur anzuzeigende Status zählen (entspricht dem Ausschluss von
// Übergangszuständen wie READY_FOR_AI, PROPOSAL_READY, PROCESSING)
if (counts.containsKey(status)) {
counts.merge(status, 1, Integer::sum);
}
}
return counts;
}
// -------------------------------------------------------------------------
// Interne Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Erzeugt den angezeigten Bannertext aus den Zählern.
* Liefert einen leeren String wenn alle Zähler null sind.
*
* @param counts die Zähler je Status; darf nicht null sein
* @return der fertige Bannertext oder ein leerer String
*/
static String buildBannerText(Map<DocumentCompletionStatus, Integer> counts) {
List<String> segments = new ArrayList<>();
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
int count = counts.getOrDefault(status, 0);
if (count > 0) {
String icon = ProcessingStatusPresentation.iconFor(status);
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
segments.add(icon + " " + count + " " + category);
}
}
return String.join(SEGMENT_SEPARATOR, segments);
}
}
@@ -6,17 +6,20 @@ import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
/**
* Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
@@ -86,9 +89,15 @@ public final class FileNameEditorPane {
saveButton.setId("filename-editor-save-button");
saveButton.setOnAction(e -> fireSaveRequest());
Tooltip saveTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
saveTooltip.setShowDelay(Duration.millis(300));
saveButton.setTooltip(saveTooltip);
resetButton.setId("filename-editor-reset-button");
resetButton.setOnAction(e -> resetToAiProposal());
Tooltip resetTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
resetTooltip.setShowDelay(Duration.millis(300));
resetButton.setTooltip(resetTooltip);
HBox buttonRow = new HBox(8, saveButton, resetButton);
buttonRow.setAlignment(Pos.CENTER_LEFT);
@@ -70,7 +70,7 @@ public record GuiBatchRunResultRow(
/**
* Icon shown in the status column when a document's persistence status has been reset.
*/
static final String RESET_PENDING_ICON = "\u27F3"; // CLOCKWISE GAPPED CIRCLE ARROW
static final String RESET_PENDING_ICON = ""; // CLOCKWISE GAPPED CIRCLE ARROW
/**
* Compact constructor normalising optional holders and validating mandatory fields.
@@ -192,25 +192,58 @@ public record GuiBatchRunResultRow(
}
/**
* Returns the status icon for this row as a Unicode character that renders reliably
* in JavaFX on Windows.
* 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>
* When {@code resetPending} is {@code true} the reset icon is returned regardless of
* the underlying status.
* 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 the corresponding status character
* @return das entsprechende Status-Zeichen
*/
public String statusIcon() {
if (resetPending) {
return RESET_PENDING_ICON;
}
return switch (status) {
case SUCCESS -> "\u2714"; // HEAVY CHECK MARK
case FAILED_RETRYABLE -> "\u26A0"; // WARNING SIGN
case FAILED_PERMANENT -> "\u2718"; // HEAVY BALLOT X
case SKIPPED_ALREADY_PROCESSED -> "\u23ED"; // NEXT TRACK BUTTON
case SKIPPED_FINAL_FAILURE -> "\u2298"; // CIRCLED DIVISION SLASH
};
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);
}
/**
@@ -228,7 +261,7 @@ public record GuiBatchRunResultRow(
return switch (status) {
case SUCCESS -> "Erfolgreich";
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
case FAILED_PERMANENT -> "Fehlgeschlagen (permanent)";
case FAILED_PERMANENT -> "Fehlgeschlagen (dauerhaft)";
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen (bereits verarbeitet)";
case SKIPPED_FINAL_FAILURE -> "Übersprungen (endgültig fehlgeschlagen)";
};
@@ -4,10 +4,10 @@ import java.nio.file.Path;
import java.time.Duration;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -67,6 +67,7 @@ import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
@@ -198,13 +199,15 @@ public final class GuiBatchRunTab {
/** PDF-Vorschau-Komponente im Detailbereich. */
private final PdfPreviewPane pdfPreview = new PdfPreviewPane();
/** Summary-Banner unterhalb des Fortschrittsbalkens sichtbar nach Laufabschluss. */
private final BatchRunSummaryBanner summaryBanner = new BatchRunSummaryBanner();
private final Supplier<Path> configPathSupplier;
private final BooleanSupplier savedConfigurationReadyCheck;
private final Runnable onRunStateChanged;
private final GuiBatchRunCoordinator coordinator;
private final Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier;
private final Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier;
private final Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier;
private final Supplier<Optional<Path>> sourceFolderSupplier;
private final Supplier<Optional<String>> targetFolderSupplier;
@@ -275,7 +278,7 @@ public final class GuiBatchRunTab {
manualFileRenamePortSupplier, "manualFileRenamePortSupplier must not be null");
this.manualFileCopyPortSupplier = Objects.requireNonNull(
manualFileCopyPortSupplier, "manualFileCopyPortSupplier must not be null");
this.historicalDocumentContextPortSupplier = Objects.requireNonNull(
Objects.requireNonNull(
historicalDocumentContextPortSupplier, "historicalDocumentContextPortSupplier must not be null");
this.sourceFolderSupplier = Objects.requireNonNull(
sourceFolderSupplier, "sourceFolderSupplier must not be null");
@@ -501,8 +504,14 @@ public final class GuiBatchRunTab {
HBox.setHgrow(progressBar, Priority.ALWAYS);
counterLabel.setId("batch-run-counter");
HBox header = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
header.setAlignment(Pos.CENTER_LEFT);
HBox progressRow = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
progressRow.setAlignment(Pos.CENTER_LEFT);
// Summary-Banner unterhalb des Fortschrittsbalkens, oberhalb der Tabelle
HBox bannerNode = summaryBanner.getNode();
bannerNode.setId("batch-run-summary-banner");
VBox header = new VBox(0, progressRow, bannerNode);
header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0));
return header;
}
@@ -606,6 +615,7 @@ public final class GuiBatchRunTab {
if (empty || icon == null) {
setText(null);
setStyle(null);
setTooltip(null);
return;
}
setText(icon);
@@ -613,9 +623,15 @@ public final class GuiBatchRunTab {
GuiBatchRunResultRow data = tableRow != null ? tableRow.getItem() : null;
if (data != null && data.resetPending()) {
setStyle("-fx-text-fill: #1565c0; -fx-alignment: CENTER; -fx-font-size: 14;");
} else {
String color = data != null ? statusColor(data.status()) : "#000000";
setTooltip(new Tooltip(data.statusTooltip()));
} else if (data != null) {
// Farbe aus zentralem Mapping nie alleiniges Unterscheidungsmerkmal
String color = ProcessingStatusPresentation.cssColorFor(data.status());
setStyle("-fx-text-fill: " + color + "; -fx-alignment: CENTER; -fx-font-size: 14;");
setTooltip(new Tooltip(data.statusTooltip()));
} else {
setStyle("-fx-alignment: CENTER; -fx-font-size: 14;");
setTooltip(null);
}
}
});
@@ -654,8 +670,8 @@ public final class GuiBatchRunTab {
}
});
resultTable.getColumns().setAll(
checkboxCol, iconCol, nameCol, newNameCol, dateCol, durationCol);
resultTable.getColumns().setAll(List.of(
checkboxCol, iconCol, nameCol, newNameCol, dateCol, durationCol));
// Selektion im TableView synchronisiert selectedRows und Checkboxen.
resultTable.getSelectionModel().getSelectedItems().addListener(
@@ -1180,6 +1196,7 @@ public final class GuiBatchRunTab {
messageArea.setVisible(false);
messageArea.setManaged(false);
messageArea.setStyle(null);
summaryBanner.clear();
resetMetrics();
updateCounterLabel();
progressBar.setProgress(0);
@@ -1420,15 +1437,7 @@ public final class GuiBatchRunTab {
// Statische Helfer
// -------------------------------------------------------------------------
private static String statusColor(DocumentCompletionStatus status) {
return switch (status) {
case SUCCESS -> "#2e7d32";
case FAILED_RETRYABLE -> "#e65100";
case FAILED_PERMANENT -> "#c62828";
case SKIPPED_ALREADY_PROCESSED -> "#1565c0";
case SKIPPED_FINAL_FAILURE -> "#757575";
};
}
// statusColor() wurde zugunsten von ProcessingStatusPresentation.cssColorFor() entfernt.
private static String formatDuration(Duration duration) {
double seconds = duration.toMillis() / 1000.0;
@@ -1476,6 +1485,14 @@ public final class GuiBatchRunTab {
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
return builder.toString();
}
if (row.status() == DocumentCompletionStatus.FAILED_PERMANENT) {
// Erweiterter Erkl\u00e4rungstext gem\u00e4\u00df Spezifikation #51 \u2013 dauerhaft fehlgeschlagen
builder.append('\n').append(ProcessingStatusPresentation.DETAIL_TEXT_FAILED_PERMANENT);
row.aiFailureMessage().ifPresent(msg ->
builder.append("\n\nFehlerdetail: ")
.append(AiFailureMessageTranslator.translate(msg)));
return builder.toString();
}
row.effectiveFileName()
.ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
row.resolvedDate()
@@ -1573,6 +1590,10 @@ public final class GuiBatchRunTab {
miniRunCompletedFingerprints = new HashSet<>();
}
selectedRows.clear();
// Summary-Banner aus der aktuellen Ergebnisliste aggregieren und anzeigen
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(resultItems);
summaryBanner.update(counts);
appendSummary(outcome);
updateButtonStates();
notifyRunStateChanged();
@@ -112,22 +112,22 @@ public final class PdfPreviewPane {
* Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread.
* {@code null} wenn kein Dokument geöffnet ist.
*/
private PDDocument currentDocument = null;
private volatile PDDocument currentDocument = null;
/**
* Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread.
* {@code null} wenn kein Dokument geöffnet ist.
*/
private PDFRenderer currentRenderer = null;
private volatile PDFRenderer currentRenderer = null;
/** Aktuell geladene Quelldatei; null wenn keine Selektion vorliegt. */
private Path currentSourceFile = null;
private volatile Path currentSourceFile = null;
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
private int currentPage = 0;
private volatile int currentPage = 0;
/** Anzahl der Seiten der aktuell geladenen PDF; -1 wenn nicht ermittelt. */
private int totalPages = -1;
private volatile int totalPages = -1;
/** Gibt an ob die Navigation bedienbar ist. */
private boolean enabled = true;
@@ -0,0 +1,257 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
/**
* Zentrale Mapping-Klasse für die visuelle Darstellung von Verarbeitungsstatus in der GUI.
* <p>
* Diese Klasse ist die einzige autoritative Quelle für Status-Icons, CSS-Farben,
* Tooltip-Texte und Summary-Kategorielabels aller {@link DocumentCompletionStatus}-Werte.
* Alle Anzeigeorte im GUI-Adapter (Ergebnistabelle, Detailbereich, Summary-Banner)
* beziehen ihre Darstellungsinformationen ausschließlich über diese Klasse.
* <p>
* Farbe ist niemals das einzige Unterscheidungsmerkmal: Icon und Tooltip-Text beschreiben
* den Status vollständig auch ohne Farb­wahrnehmung.
* <p>
* Diese Klasse enthält keine JavaFX-Typen; sie ist rein datenhaltend und zustandslos.
* Alle Methoden sind statisch.
*/
public final class ProcessingStatusPresentation {
// -------------------------------------------------------------------------
// Icons (Unicode-Zeichen, zuverlässig darstellbar unter Windows 10+)
// -------------------------------------------------------------------------
/** Icon für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String ICON_SUCCESS = ""; // CHECK MARK
/** Icon für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String ICON_FAILED_RETRYABLE = ""; // CLOCKWISE OPEN CIRCLE ARROW
/** Icon für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String ICON_FAILED_PERMANENT = "×"; // MULTIPLICATION SIGN
/** Icon für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String ICON_SKIPPED_ALREADY_PROCESSED = ""; // IDENTICAL TO
/** Icon für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String ICON_SKIPPED_FINAL_FAILURE = ""; // CIRCLED DIVISION SLASH
// -------------------------------------------------------------------------
// CSS-Farben (Hex-Strings für JavaFX setStyle)
// -------------------------------------------------------------------------
/** CSS-Farbe für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String COLOR_SUCCESS = "#2e7d32"; // Grün
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String COLOR_FAILED_RETRYABLE = "#d98200"; // Orange
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String COLOR_FAILED_PERMANENT = "#c62828"; // Rot
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String COLOR_SKIPPED_ALREADY_PROCESSED = "#757575"; // Grau
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String COLOR_SKIPPED_FINAL_FAILURE = "#424242"; // Dunkelgrau
// -------------------------------------------------------------------------
// Tooltip-Texte (deutsche Benutzertexte, gemäß Spezifikation)
// -------------------------------------------------------------------------
/** Tooltip für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String TOOLTIP_SUCCESS =
"Erfolgreich verarbeitet und umbenannt.";
/** Tooltip für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String TOOLTIP_FAILED_RETRYABLE =
"Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht.";
/** Tooltip für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String TOOLTIP_FAILED_PERMANENT =
"Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), Passwortschutz "
+ "oder beschädigte Datei. Kein weiterer automatischer Versuch.";
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String TOOLTIP_SKIPPED_ALREADY_PROCESSED =
"Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String TOOLTIP_SKIPPED_FINAL_FAILURE =
"Endgültig übersprungen nach wiederholten Fehlern.";
// -------------------------------------------------------------------------
// Detailtext für FAILED_PERMANENT (Erklärung im Detailbereich)
// -------------------------------------------------------------------------
/**
* Erweiterter Erklärungstext, der im Detailbereich bei dauerhaft fehlgeschlagenen
* Dokumenten angezeigt wird.
*/
public static final String DETAIL_TEXT_FAILED_PERMANENT =
"Diese Datei kann nicht verarbeitet werden. Mögliche Ursachen: "
+ "kein lesbarer Text (z. B. gescanntes Foto ohne OCR), Passwortschutz "
+ "oder beschädigte Datei. "
+ "Sie können den Status manuell zurücksetzen, wenn Sie die Ursache behoben haben.";
// -------------------------------------------------------------------------
// Summary-Kategorielabels
// -------------------------------------------------------------------------
/** Summary-Kategorie für {@link DocumentCompletionStatus#SUCCESS}. */
public static final String SUMMARY_CATEGORY_SUCCESS = "erfolgreich";
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
public static final String SUMMARY_CATEGORY_FAILED_RETRYABLE = "wird wiederholt";
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
public static final String SUMMARY_CATEGORY_FAILED_PERMANENT = "fehlgeschlagen";
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
public static final String SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED = "übersprungen";
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
public static final String SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE = "endgültig übersprungen";
// -------------------------------------------------------------------------
// Record-Typ für gebündelte Darstellungsinformationen
// -------------------------------------------------------------------------
/**
* Gebündelte visuelle Darstellungsinformationen für einen Verarbeitungsstatus.
*
* @param icon Unicode-Zeichen als Status-Icon; nie leer
* @param cssColor CSS-Hex-Farbe für das Icon, z. B. {@code "#2e7d32"}; nie leer
* @param tooltipText Deutschsprachiger Tooltip-Text; nie leer
* @param summaryCategoryLabel Kategorie-Bezeichnung für das Summary-Banner; nie leer
*/
public record StatusVisuals(
String icon,
String cssColor,
String tooltipText,
String summaryCategoryLabel) {
/**
* Kompakter Konstruktor zur Pflichtfeld-Validierung.
*
* @throws NullPointerException wenn ein Feld {@code null} ist
* @throws IllegalArgumentException wenn ein String-Feld leer ist
*/
public StatusVisuals {
Objects.requireNonNull(icon, "icon muss gesetzt sein");
Objects.requireNonNull(cssColor, "cssColor muss gesetzt sein");
Objects.requireNonNull(tooltipText, "tooltipText muss gesetzt sein");
Objects.requireNonNull(summaryCategoryLabel, "summaryCategoryLabel muss gesetzt sein");
if (icon.isBlank()) throw new IllegalArgumentException("icon darf nicht leer sein");
if (cssColor.isBlank()) throw new IllegalArgumentException("cssColor darf nicht leer sein");
if (tooltipText.isBlank()) throw new IllegalArgumentException("tooltipText darf nicht leer sein");
if (summaryCategoryLabel.isBlank())
throw new IllegalArgumentException("summaryCategoryLabel darf nicht leer sein");
}
}
// -------------------------------------------------------------------------
// Zentrale Mapping-Methoden
// -------------------------------------------------------------------------
/**
* Liefert das Status-Icon für den angegebenen Verarbeitungsstatus.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return das zugehörige Unicode-Zeichen; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String iconFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein");
return switch (status) {
case SUCCESS -> ICON_SUCCESS;
case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE;
case FAILED_PERMANENT -> ICON_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> ICON_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> ICON_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert die CSS-Hex-Farbe für das Status-Icon des angegebenen Verarbeitungsstatus.
* <p>
* Die Farbe ist nie das einzige Unterscheidungsmerkmal Icon und Tooltip-Text
* beschreiben den Status unabhängig von der Farbe eindeutig.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return die CSS-Hex-Farbe (z. B. {@code "#2e7d32"}); nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String cssColorFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein");
return switch (status) {
case SUCCESS -> COLOR_SUCCESS;
case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE;
case FAILED_PERMANENT -> COLOR_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> COLOR_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> COLOR_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert den deutschsprachigen Tooltip-Text für den angegebenen Verarbeitungsstatus.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return der Tooltip-Text; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String tooltipFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein");
return switch (status) {
case SUCCESS -> TOOLTIP_SUCCESS;
case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE;
case FAILED_PERMANENT -> TOOLTIP_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> TOOLTIP_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> TOOLTIP_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert die Summary-Kategorie-Bezeichnung für den angegebenen Verarbeitungsstatus.
* Diese Kategorie wird im Summary-Banner nach einem Lauf angezeigt.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return die Kategorienbezeichnung; nie leer
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static String summaryCategoryFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein");
return switch (status) {
case SUCCESS -> SUMMARY_CATEGORY_SUCCESS;
case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE;
case FAILED_PERMANENT -> SUMMARY_CATEGORY_FAILED_PERMANENT;
case SKIPPED_ALREADY_PROCESSED -> SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED;
case SKIPPED_FINAL_FAILURE -> SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE;
};
}
/**
* Liefert alle gebündelten visuellen Darstellungsinformationen für den angegebenen
* Verarbeitungsstatus in einem einzigen Objekt.
*
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
* @return ein befülltes {@link StatusVisuals}-Record; nie {@code null}
* @throws NullPointerException wenn {@code status} {@code null} ist
*/
public static StatusVisuals visualsFor(DocumentCompletionStatus status) {
Objects.requireNonNull(status, "status darf nicht null sein");
return new StatusVisuals(
iconFor(status),
cssColorFor(status),
tooltipFor(status),
summaryCategoryFor(status));
}
/** Nicht instanziierbar reine Utility-Klasse. */
private ProcessingStatusPresentation() {
throw new UnsupportedOperationException("Nicht instanziierbar");
}
}
@@ -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,794 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SplitPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
/**
* Dritter Haupt-Tab des JavaFX-Editorfensters: der Historien-Tab Verlauf".
* <p>
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
*
* <h2>Layout</h2>
* <pre>
*
* [ Suchfeld ] [ Status ] [ Aktualisieren ]
*
* Dokumentenliste (~55%) Detailbereich (~45%)
* Dokument-Info
* Versuche-Tabelle
* KI-Begründung
*
* [ Status zurücksetzen ] [ Eintrag löschen ] Statuszeile
*
* </pre>
*
* <h2>Threading</h2>
* <p>Alle DB-Zugriffe laufen auf einem Hintergrund-Worker-Thread.
* UI-Updates erfolgen ausschließlich via {@code Platform.runLater()}.
* Destruktive Aktionen (Reset, Löschen) sind während eines aktiven
* Verarbeitungslaufs deaktiviert.
*/
public final class GuiHistoryTab {
private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class);
private static final String TAB_TITLE = "Verlauf";
private static final String EMPTY_DB_TEXT = "Noch keine Verarbeitungen vorhanden.";
private static final String TOO_MANY_RESULTS_TEXT =
"Weitere Einträge vorhanden Filter verwenden um die Trefferliste einzuschränken.";
private static final String DETAIL_PLACEHOLDER = "Dokument auswählen für Details";
private static final String NO_REASONING_TEXT = "Kein KI-Reasoning für diesen Versuch vorhanden.";
private static final String LOADING_TEXT = "Wird geladen …";
private static final String LAUF_AKTIV_HINWEIS = "Aktion während Verarbeitungslauf nicht möglich.";
private static final DateTimeFormatter TIMESTAMP_FMT =
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm").withZone(ZoneId.systemDefault());
// ---- Bridge-Ports ---------------------------------------------------
private final GuiHistoryOverviewPort overviewPort;
private final GuiHistoryDetailsPort detailsPort;
private final GuiHistoryResetDocumentStatusPort resetPort;
private final GuiDeleteDocumentHistoryPort deletePort;
private final BooleanSupplier runningCheck;
/** Liefert den Pfad zur aktuell geladenen Konfigurationsdatei, oder {@code null} wenn keine geladen. */
private final Supplier<Path> configPathSupplier;
// ---- JavaFX-Knoten --------------------------------------------------
private final Tab tab = new Tab(TAB_TITLE);
private final TextField searchField = new TextField();
private final ComboBox<String> statusFilterBox = new ComboBox<>();
private final Button refreshButton = new Button("Aktualisieren");
private final TableView<DocumentHistoryRow> overviewTable = new TableView<>();
private final ObservableList<DocumentHistoryRow> overviewItems = FXCollections.observableArrayList();
private final Label statusBarLabel = new Label();
private final Label moreThanMaxLabel = new Label();
// Detailbereich
private final GridPane detailGrid = new GridPane();
private final Label detailFingerprintLabel = new Label();
private final Label detailSourceFileLabel = new Label();
private final Label detailSourcePathLabel = new Label();
private final Label detailStatusLabel = new Label();
private final Label detailCreatedLabel = new Label();
private final Label detailUpdatedLabel = new Label();
private final TableView<ProcessingAttempt> attemptsTable = new TableView<>();
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
private final TextArea reasoningArea = new TextArea();
private final Button resetButton = new Button("Status zurücksetzen");
private final Button deleteButton = new Button("Eintrag löschen");
// ---- Zustand --------------------------------------------------------
private final ExecutorService workerPool;
/**
* Erzeugt den Historien-Tab.
*
* @param overviewPort Brücke zur Dokumentenübersicht; darf nicht {@code null} sein
* @param detailsPort Brücke zur Detailansicht; darf nicht {@code null} sein
* @param resetPort Brücke zum feldgenauen Status-Reset; darf nicht {@code null} sein
* @param deletePort Brücke zum vollständigen Löschen; darf nicht {@code null} sein
* @param runningCheck Liefert {@code true} wenn gerade ein Verarbeitungslauf aktiv ist;
* darf nicht {@code null} sein
* @param configPathSupplier Liefert den Pfad zur aktuell geladenen Konfigurationsdatei,
* oder {@code null} wenn keine geladen ist; darf nicht {@code null} sein
*/
public GuiHistoryTab(
GuiHistoryOverviewPort overviewPort,
GuiHistoryDetailsPort detailsPort,
GuiHistoryResetDocumentStatusPort resetPort,
GuiDeleteDocumentHistoryPort deletePort,
BooleanSupplier runningCheck,
Supplier<Path> configPathSupplier) {
this.overviewPort = Objects.requireNonNull(overviewPort, "overviewPort darf nicht null sein");
this.detailsPort = Objects.requireNonNull(detailsPort, "detailsPort darf nicht null sein");
this.resetPort = Objects.requireNonNull(resetPort, "resetPort darf nicht null sein");
this.deletePort = Objects.requireNonNull(deletePort, "deletePort darf nicht null sein");
this.runningCheck = Objects.requireNonNull(runningCheck, "runningCheck darf nicht null sein");
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier darf nicht null sein");
this.workerPool = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "HistoryTabWorker");
t.setDaemon(true);
return t;
});
buildUi();
wireEvents();
tab.setClosable(false);
}
/**
* Liefert den JavaFX-{@link Tab}, der in die TabPane eingefügt werden kann.
*
* @return der Tab; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Lädt die Dokumentenübersicht neu muss auf dem JavaFX Application Thread aufgerufen werden.
* Wird vom Tab-Wechsel-Listener ausgelöst.
*/
public void refresh() {
loadOverview();
}
// =========================================================================
// UI-Aufbau
// =========================================================================
private void buildUi() {
// --- Toolbar ---
searchField.setPromptText("Suche nach Dateiname …");
searchField.setPrefWidth(300);
Tooltip.install(searchField, new Tooltip(
"Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal)."));
statusFilterBox.getItems().add("Alle Status");
for (ProcessingStatus s : ProcessingStatus.values()) {
statusFilterBox.getItems().add(s.name());
}
statusFilterBox.getSelectionModel().selectFirst();
Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen."));
refreshButton.setTooltip(new Tooltip("Dokumentenliste neu aus der Datenbank laden."));
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
HBox toolbar = new HBox(8, searchField, statusFilterBox, spacer, refreshButton);
toolbar.setAlignment(Pos.CENTER_LEFT);
toolbar.setPadding(new Insets(6, 8, 6, 8));
// --- Dokumentenliste (links) ---
buildOverviewTable();
moreThanMaxLabel.setStyle("-fx-text-fill: #d98200; -fx-font-style: italic;");
moreThanMaxLabel.setVisible(false);
moreThanMaxLabel.setManaged(false);
VBox leftPane = new VBox(4, overviewTable, moreThanMaxLabel);
VBox.setVgrow(overviewTable, Priority.ALWAYS);
leftPane.setPadding(new Insets(0, 4, 0, 0));
// --- Detailbereich (rechts) ---
VBox rightPane = buildDetailPane();
// --- SplitPane ---
SplitPane splitPane = new SplitPane(leftPane, rightPane);
splitPane.setDividerPositions(0.55);
// --- Aktionsleiste unten ---
resetButton.setTooltip(new Tooltip(
"Setzt Status, Fehlerzähler und letzten Fehlerzeitpunkt zurück. "
+ "Versuche bleiben erhalten. Das Dokument wird beim nächsten Lauf erneut verarbeitet."));
deleteButton.setTooltip(new Tooltip(
"Löscht den Eintrag und alle Versuche vollständig. "
+ "Diese Aktion ist nicht rückgängig zu machen."));
resetButton.setDisable(true);
deleteButton.setDisable(true);
statusBarLabel.setStyle("-fx-text-fill: #555555; -fx-font-style: italic;");
HBox actionBar = new HBox(8, resetButton, deleteButton, spacerNew(), statusBarLabel);
actionBar.setAlignment(Pos.CENTER_LEFT);
actionBar.setPadding(new Insets(6, 8, 6, 8));
// --- Gesamtlayout ---
BorderPane content = new BorderPane();
content.setTop(toolbar);
content.setCenter(splitPane);
content.setBottom(actionBar);
BorderPane.setMargin(toolbar, Insets.EMPTY);
tab.setContent(content);
}
private void buildOverviewTable() {
overviewTable.setItems(overviewItems);
overviewTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
// Status-Icon-Spalte
TableColumn<DocumentHistoryRow, String> statusCol = new TableColumn<>("Status");
statusCol.setCellValueFactory(cell ->
new SimpleStringProperty(statusIcon(cell.getValue().overallStatus())));
statusCol.setCellFactory(col -> new TableCell<>() {
@Override
protected void updateItem(String icon, boolean empty) {
super.updateItem(icon, empty);
if (empty || icon == null) {
setText(null);
setTooltip(null);
} else {
setText(icon);
DocumentHistoryRow row = getTableView().getItems().get(getIndex());
setStyle("-fx-text-fill: " + statusColor(row.overallStatus()) + "; -fx-font-weight: bold;");
setTooltip(new Tooltip(statusTooltip(row.overallStatus())));
}
}
});
statusCol.setPrefWidth(60);
statusCol.setMaxWidth(70);
// Quelldateiname
TableColumn<DocumentHistoryRow, String> sourceCol = new TableColumn<>("Quelldatei");
sourceCol.setCellValueFactory(cell ->
new SimpleStringProperty(cell.getValue().sourceFileName()));
sourceCol.setCellFactory(col -> ellipsisCell());
// Zieldateiname
TableColumn<DocumentHistoryRow, String> targetCol = new TableColumn<>("Zieldatei");
targetCol.setCellValueFactory(cell ->
new SimpleStringProperty(
cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : ""));
targetCol.setCellFactory(col -> ellipsisCell());
// Letzter Versuch
TableColumn<DocumentHistoryRow, String> updatedCol = new TableColumn<>("Letzter Versuch");
updatedCol.setCellValueFactory(cell ->
new SimpleStringProperty(formatInstant(cell.getValue().updatedAt())));
updatedCol.setPrefWidth(140);
updatedCol.setMaxWidth(160);
// Anzahl Versuche
TableColumn<DocumentHistoryRow, String> countCol = new TableColumn<>("Versuche");
countCol.setCellValueFactory(cell ->
new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount())));
countCol.setPrefWidth(70);
countCol.setMaxWidth(80);
overviewTable.getColumns().setAll(statusCol, sourceCol, targetCol, updatedCol, countCol);
}
private VBox buildDetailPane() {
// Dokument-Info
detailGrid.setHgap(8);
detailGrid.setVgap(4);
detailGrid.setPadding(new Insets(8));
addDetailRow(0, "Fingerprint:", detailFingerprintLabel);
addDetailRow(1, "Quelldatei:", detailSourceFileLabel);
addDetailRow(2, "Quellpfad:", detailSourcePathLabel);
addDetailRow(3, "Status:", detailStatusLabel);
addDetailRow(4, "Erstellt:", detailCreatedLabel);
addDetailRow(5, "Aktualisiert:", detailUpdatedLabel);
Label detailTitle = new Label("Dokument-Details");
detailTitle.setStyle("-fx-font-weight: bold;");
// Versuche-Tabelle
buildAttemptsTable();
Label attemptsTitle = new Label("Verarbeitungsversuche");
attemptsTitle.setStyle("-fx-font-weight: bold;");
// KI-Begründung
reasoningArea.setEditable(false);
reasoningArea.setWrapText(true);
reasoningArea.setPrefRowCount(4);
reasoningArea.setText(DETAIL_PLACEHOLDER);
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
reasoningTitle.setStyle("-fx-font-weight: bold;");
VBox rightPane = new VBox(8,
detailTitle, detailGrid,
attemptsTitle, attemptsTable,
reasoningTitle, reasoningArea);
rightPane.setPadding(new Insets(4, 8, 4, 4));
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
ScrollPane scroll = new ScrollPane(rightPane);
scroll.setFitToWidth(true);
scroll.setFitToHeight(true);
VBox wrapper = new VBox(scroll);
VBox.setVgrow(scroll, Priority.ALWAYS);
return wrapper;
}
private void buildAttemptsTable() {
attemptsTable.setItems(attemptsItems);
attemptsTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
attemptsTable.setPlaceholder(new Label("Keine Versuche vorhanden."));
attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
attemptsTable.setPrefHeight(150);
TableColumn<ProcessingAttempt, String> numCol = new TableColumn<>("#");
numCol.setCellValueFactory(c ->
new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber())));
numCol.setPrefWidth(40);
numCol.setMaxWidth(50);
TableColumn<ProcessingAttempt, String> dateCol = new TableColumn<>("Datum");
dateCol.setCellValueFactory(c ->
new SimpleStringProperty(formatInstant(c.getValue().endedAt())));
dateCol.setPrefWidth(130);
dateCol.setMaxWidth(150);
TableColumn<ProcessingAttempt, String> statusCol = new TableColumn<>("Status");
statusCol.setCellValueFactory(c ->
new SimpleStringProperty(
statusIcon(c.getValue().status()) + " " + c.getValue().status().name()));
statusCol.setPrefWidth(140);
TableColumn<ProcessingAttempt, String> providerCol = new TableColumn<>("Provider");
providerCol.setCellValueFactory(c ->
new SimpleStringProperty(
c.getValue().aiProvider() != null ? c.getValue().aiProvider() : ""));
providerCol.setPrefWidth(90);
TableColumn<ProcessingAttempt, String> modelCol = new TableColumn<>("Modell");
modelCol.setCellValueFactory(c ->
new SimpleStringProperty(
c.getValue().modelName() != null ? c.getValue().modelName() : ""));
modelCol.setCellFactory(col -> ellipsisCell());
TableColumn<ProcessingAttempt, String> fileNameCol = new TableColumn<>("Vorgeschlagener Name");
fileNameCol.setCellValueFactory(c ->
new SimpleStringProperty(
c.getValue().finalTargetFileName() != null
? c.getValue().finalTargetFileName() : ""));
fileNameCol.setCellFactory(col -> ellipsisCell());
attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol, fileNameCol);
}
// =========================================================================
// Event-Verdrahtung
// =========================================================================
private void wireEvents() {
refreshButton.setOnAction(e -> loadOverview());
// Debounce-artige Aktualisierung bei Texteingabe: direkte Suche bei Enter,
// sonst über Fokus-Verlust oder expliziten Aktualisieren-Button
searchField.setOnAction(e -> loadOverview());
statusFilterBox.setOnAction(e -> loadOverview());
// Detailbereich bei Zeilenselektion
overviewTable.getSelectionModel().selectedItemProperty().addListener(
(obs, old, selected) -> {
if (selected == null) {
clearDetailPane();
resetButton.setDisable(true);
deleteButton.setDisable(true);
} else {
resetButton.setDisable(runningCheck.getAsBoolean());
deleteButton.setDisable(runningCheck.getAsBoolean());
loadDetails(selected.fingerprint());
}
});
resetButton.setOnAction(e -> handleResetAction());
deleteButton.setOnAction(e -> handleDeleteAction());
// Tab soll beim ersten Betreten automatisch laden
tab.selectedProperty().addListener((obs, oldVal, selected) -> {
if (Boolean.TRUE.equals(selected)) {
loadOverview();
}
});
}
// =========================================================================
// Daten laden (Worker-Thread)
// =========================================================================
private void loadOverview() {
statusBarLabel.setText(LOADING_TEXT);
overviewItems.clear();
moreThanMaxLabel.setVisible(false);
moreThanMaxLabel.setManaged(false);
Path configPath = configPathSupplier.get();
if (configPath == null) {
statusBarLabel.setText("Keine Konfiguration geladen bitte zuerst eine Konfigurationsdatei öffnen.");
overviewTable.setPlaceholder(new Label("Keine Konfiguration geladen."));
return;
}
String searchText = searchField.getText();
String selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem();
String statusFilter = (selectedStatus == null || "Alle Status".equals(selectedStatus))
? null : selectedStatus;
HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT);
workerPool.submit(() -> {
try {
HistoryOverviewResult result = overviewPort.loadOverview(configPath, query);
Platform.runLater(() -> {
overviewItems.setAll(result.rows());
if (result.hasMore()) {
moreThanMaxLabel.setText(TOO_MANY_RESULTS_TEXT);
moreThanMaxLabel.setVisible(true);
moreThanMaxLabel.setManaged(true);
} else {
moreThanMaxLabel.setVisible(false);
moreThanMaxLabel.setManaged(false);
}
if (result.rows().isEmpty()) {
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
statusBarLabel.setText("Keine Einträge gefunden.");
} else {
statusBarLabel.setText(result.rows().size() + " Einträge geladen.");
}
});
} catch (Exception ex) {
LOG.error("Fehler beim Laden der Historienübersicht: {}", ex.getMessage(), ex);
Platform.runLater(() ->
statusBarLabel.setText("Fehler beim Laden: " + ex.getMessage()));
}
});
}
private void loadDetails(DocumentFingerprint fingerprint) {
reasoningArea.setText(LOADING_TEXT);
attemptsItems.clear();
clearDetailFields();
Path configPath = configPathSupplier.get();
if (configPath == null) {
reasoningArea.setText(DETAIL_PLACEHOLDER);
return;
}
workerPool.submit(() -> {
try {
Optional<HistoryDetailsResult> result = detailsPort.loadDetails(configPath, fingerprint);
Platform.runLater(() -> {
if (result.isEmpty()) {
clearDetailPane();
statusBarLabel.setText("Eintrag nicht mehr vorhanden.");
} else {
populateDetailPane(result.get());
}
});
} catch (Exception ex) {
LOG.error("Fehler beim Laden der Dokumentdetails für {}: {}",
fingerprint.sha256Hex(), ex.getMessage(), ex);
Platform.runLater(() -> {
reasoningArea.setText("Fehler beim Laden der Details: " + ex.getMessage());
statusBarLabel.setText("Fehler beim Laden der Details.");
});
}
});
}
// =========================================================================
// Aktionen
// =========================================================================
private void handleResetAction() {
if (runningCheck.getAsBoolean()) {
showInfo(LAUF_AKTIV_HINWEIS);
return;
}
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
if (selected == null) return;
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
confirm.setTitle("Status zurücksetzen");
confirm.setHeaderText("Status zurücksetzen?");
confirm.setContentText(
"Setzt den Status des Dokuments auf READY_FOR_AI zurück.\n"
+ "Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n"
+ "Die Versuchshistorie bleibt vollständig erhalten.\n\n"
+ "Das Dokument wird beim nächsten Verarbeitungslauf erneut verarbeitet.\n\n"
+ "Quelldatei: " + selected.sourceFileName());
Optional<ButtonType> choice = confirm.showAndWait();
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
DocumentFingerprint fp = selected.fingerprint();
Path configPath = configPathSupplier.get();
if (configPath == null) {
showInfo("Keine Konfiguration geladen.");
return;
}
resetButton.setDisable(true);
deleteButton.setDisable(true);
statusBarLabel.setText("Status wird zurückgesetzt …");
workerPool.submit(() -> {
try {
resetPort.resetStatus(configPath, fp);
LOG.info("Status-Reset durchgeführt für Fingerprint: {}", fp.sha256Hex());
Platform.runLater(() -> {
statusBarLabel.setText("Status erfolgreich zurückgesetzt.");
loadOverview();
});
} catch (Exception ex) {
LOG.error("Status-Reset fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
Platform.runLater(() -> {
statusBarLabel.setText("Fehler beim Status-Reset: " + ex.getMessage());
resetButton.setDisable(false);
deleteButton.setDisable(false);
});
}
});
}
private void handleDeleteAction() {
if (runningCheck.getAsBoolean()) {
showInfo(LAUF_AKTIV_HINWEIS);
return;
}
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
if (selected == null) return;
Alert confirm = new Alert(Alert.AlertType.WARNING);
confirm.setTitle("Eintrag löschen");
confirm.setHeaderText("Eintrag vollständig löschen?");
confirm.setContentText(
"Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n"
+ "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
+ "Quelldatei: " + selected.sourceFileName());
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
Optional<ButtonType> choice = confirm.showAndWait();
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
DocumentFingerprint fp = selected.fingerprint();
Path configPath = configPathSupplier.get();
if (configPath == null) {
showInfo("Keine Konfiguration geladen.");
return;
}
resetButton.setDisable(true);
deleteButton.setDisable(true);
statusBarLabel.setText("Eintrag wird gelöscht …");
workerPool.submit(() -> {
try {
deletePort.deleteHistory(configPath, fp);
LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", fp.sha256Hex());
Platform.runLater(() -> {
statusBarLabel.setText("Eintrag erfolgreich gelöscht.");
clearDetailPane();
loadOverview();
});
} catch (Exception ex) {
LOG.error("Löschen fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
Platform.runLater(() -> {
statusBarLabel.setText("Fehler beim Löschen: " + ex.getMessage());
resetButton.setDisable(false);
deleteButton.setDisable(false);
});
}
});
}
// =========================================================================
// Detail-Bereich befüllen / leeren
// =========================================================================
private void populateDetailPane(HistoryDetailsResult result) {
DocumentRecord record = result.record();
String fpFull = record.fingerprint().sha256Hex();
detailFingerprintLabel.setText(fpFull.substring(0, Math.min(12, fpFull.length())) + "");
detailFingerprintLabel.setTooltip(new Tooltip(fpFull));
detailSourceFileLabel.setText(record.lastKnownSourceFileName());
detailSourcePathLabel.setText(record.lastKnownSourceLocator().value());
detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value()));
String icon = statusIcon(record.overallStatus());
detailStatusLabel.setText(icon + " " + record.overallStatus().name());
detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";");
detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus())));
detailCreatedLabel.setText(formatInstant(record.createdAt()));
detailUpdatedLabel.setText(formatInstant(record.updatedAt()));
attemptsItems.setAll(result.attempts());
// Neuesten Versuch selektieren und Begründung anzeigen
if (!result.attempts().isEmpty()) {
ProcessingAttempt last = result.attempts().get(result.attempts().size() - 1);
attemptsTable.getSelectionModel().select(last);
showReasoning(last);
} else {
reasoningArea.setText(NO_REASONING_TEXT);
}
// KI-Begründung bei Versuchs-Selektion aktualisieren
attemptsTable.getSelectionModel().selectedItemProperty().addListener(
(obs, old, attempt) -> {
if (attempt != null) {
showReasoning(attempt);
}
});
}
private void showReasoning(ProcessingAttempt attempt) {
String reasoning = attempt.aiReasoning();
reasoningArea.setText(reasoning != null && !reasoning.isBlank()
? reasoning : NO_REASONING_TEXT);
}
private void clearDetailPane() {
clearDetailFields();
attemptsItems.clear();
reasoningArea.setText(DETAIL_PLACEHOLDER);
}
private void clearDetailFields() {
detailFingerprintLabel.setText("");
detailFingerprintLabel.setTooltip(null);
detailSourceFileLabel.setText("");
detailSourcePathLabel.setText("");
detailSourcePathLabel.setTooltip(null);
detailStatusLabel.setText("");
detailStatusLabel.setStyle("");
detailStatusLabel.setTooltip(null);
detailCreatedLabel.setText("");
detailUpdatedLabel.setText("");
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
private void addDetailRow(int row, String labelText, Label valueLabel) {
Label label = new Label(labelText);
label.setStyle("-fx-font-weight: bold;");
valueLabel.setMaxWidth(Double.MAX_VALUE);
GridPane.setHgrow(valueLabel, Priority.ALWAYS);
detailGrid.add(label, 0, row);
detailGrid.add(valueLabel, 1, row);
}
private String formatInstant(Instant instant) {
if (instant == null) return "";
return TIMESTAMP_FMT.format(instant);
}
private static String statusIcon(ProcessingStatus status) {
if (status == null) return "?";
return switch (status) {
case SUCCESS -> "";
case FAILED_RETRYABLE -> "";
case FAILED_FINAL -> "×";
case SKIPPED_ALREADY_PROCESSED -> "";
case SKIPPED_FINAL_FAILURE -> "";
case READY_FOR_AI -> "";
case PROPOSAL_READY -> "";
case PROCESSING -> "";
};
}
private static String statusColor(ProcessingStatus status) {
if (status == null) return "#000000";
return switch (status) {
case SUCCESS -> "#2e7d32";
case FAILED_RETRYABLE -> "#d98200";
case FAILED_FINAL -> "#c62828";
case SKIPPED_ALREADY_PROCESSED -> "#757575";
case SKIPPED_FINAL_FAILURE -> "#424242";
case READY_FOR_AI -> "#1565c0";
case PROPOSAL_READY -> "#0288d1";
case PROCESSING -> "#9e9e9e";
};
}
private static String statusTooltip(ProcessingStatus status) {
if (status == null) return "";
return switch (status) {
case SUCCESS -> "Erfolgreich verarbeitet und umbenannt.";
case FAILED_RETRYABLE -> "Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht.";
case FAILED_FINAL -> "Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), "
+ "Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch.";
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
case SKIPPED_FINAL_FAILURE -> "Endgültig übersprungen nach wiederholten Fehlern.";
case READY_FOR_AI -> "Wartet auf Verarbeitung.";
case PROPOSAL_READY -> "KI-Vorschlag liegt vor, wartet auf Bestätigung.";
case PROCESSING -> "Wird gerade verarbeitet.";
};
}
private static <T> TableCell<T, String> ellipsisCell() {
return new TableCell<>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setTooltip(null);
} else {
setText(item);
setTooltip(new Tooltip(item));
}
}
};
}
private static Region spacerNew() {
Region r = new Region();
HBox.setHgrow(r, Priority.ALWAYS);
return r;
}
private void showInfo(String message) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle("Hinweis");
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
}
@@ -0,0 +1,15 @@
/**
* GUI-Adapter für den Historien-Tab.
* <p>
* Enthält die Bridge-Interfaces {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort} und
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort}
* sowie die JavaFX-Komponente {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab}.
* <p>
* Die Bridge-Interfaces werden von Bootstrap implementiert und über
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext} in den GUI-Adapter injiziert.
* Die GUI-Komponenten kennen ausschließlich diese Interfaces
* niemals direkt Repository- oder Use-Case-Implementierungen.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
@@ -244,12 +244,16 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
assertEquals(2, workspace.tabPane().getTabs().size(),
"Configuration tab and processing-run tab must both be present");
assertEquals(4, workspace.tabPane().getTabs().size(),
"Configuration tab, processing-run tab, history tab and prompt editor tab must all be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The first tab must use the configuration label");
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
"The second tab must host the processing-run view");
assertEquals("Verlauf", workspace.tabPane().getTabs().get(2).getText(),
"The third tab must host the history view");
assertEquals("Prompt", workspace.tabPane().getTabs().get(3).getText(),
"The fourth tab must host the prompt editor");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),
@@ -415,7 +419,8 @@ class GuiAdapterSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -202,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));
@@ -345,7 +345,8 @@ class GuiEditorFieldBindingTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -137,7 +137,8 @@ class GuiEditorIntegrationTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -287,7 +288,8 @@ class GuiEditorIntegrationTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -371,7 +373,8 @@ class GuiEditorIntegrationTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -208,7 +208,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -347,7 +348,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -471,7 +473,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -599,7 +602,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -698,7 +702,8 @@ class GuiEditorRegressionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -142,7 +142,8 @@ class GuiEditorValidationSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -272,7 +273,8 @@ class GuiEditorValidationSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -0,0 +1,205 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import javafx.application.Platform;
import javafx.scene.control.Tab;
/**
* Monocle-basierte Headless-Smoke-Tests für {@link GuiHistoryTab}.
* <p>
* Geprüfte Szenarien:
* <ul>
* <li>Tab wird mit Titel Verlauf" erstellt.</li>
* <li>Tab ist nicht schließbar.</li>
* <li>Ohne geladene Konfiguration bleibt die Übersicht leer (null-configPath).</li>
* <li>Mit leerem Übersichts-Port bleibt die Tabelle leer.</li>
* </ul>
*/
class GuiHistoryTabSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform muss innerhalb des Timeouts starten");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Gemeinsame Platform kein Platform.exit().
}
// =========================================================================
// Stubs
// =========================================================================
private static GuiHistoryOverviewPort emptyOverviewPort() {
return (configFilePath, query) ->
new HistoryOverviewResult(List.of(), false);
}
private static GuiHistoryDetailsPort emptyDetailsPort() {
return (configFilePath, fingerprint) -> Optional.empty();
}
private static GuiHistoryResetDocumentStatusPort noOpResetPort() {
return (configFilePath, fingerprint) -> { /* no-op */ };
}
private static GuiDeleteDocumentHistoryPort noOpDeletePort() {
return (configFilePath, fingerprint) -> { /* no-op */ };
}
private static GuiHistoryTab buildTab(Path configPath) {
return new GuiHistoryTab(
emptyOverviewPort(),
emptyDetailsPort(),
noOpResetPort(),
noOpDeletePort(),
() -> false,
() -> configPath);
}
// =========================================================================
// Tests
// =========================================================================
@Test
void tab_shouldHaveTitleVerlauf() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<Tab> tabRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
GuiHistoryTab historyTab = buildTab(null);
tabRef.set(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
assertEquals("Verlauf", tabRef.get().getText(), "Tab-Titel muss 'Verlauf' sein");
}
@Test
void tab_shouldNotBeClosable() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean closableRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
GuiHistoryTab historyTab = buildTab(null);
closableRef.set(historyTab.tab().isClosable());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(closableRef.get(), "Tab darf nicht schließbar sein");
}
@Test
void construction_withNullConfigPath_doesNotThrow() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
// Konstruktion mit null-configPath-Supplier muss möglich sein
GuiHistoryTab historyTab = buildTab(null);
assertNotNull(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
}
@Test
void construction_withConfigPath_doesNotThrow() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
Path dummyPath = Paths.get("config/application.properties");
GuiHistoryTab historyTab = buildTab(dummyPath);
assertNotNull(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
}
}
@@ -336,7 +336,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -478,7 +479,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -565,7 +567,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -888,7 +891,8 @@ class GuiMessageAreaSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -529,7 +529,8 @@ class GuiModelCatalogSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -0,0 +1,371 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
import javafx.application.Platform;
import javafx.scene.control.Tab;
/**
* Monocle-basierte Headless-Smoke-Tests für {@link GuiPromptEditorTab}.
* <p>
* Geprüfte Szenarien:
* <ul>
* <li>Tab wird korrekt mit Titel Prompt" erstellt.</li>
* <li>Dirty-State ist nach Konstruktion {@code false}.</li>
* <li>Nach synchronem Laden mit Erfolg: Dirty-State bleibt {@code false},
* Tab-Titel enthält keinen Asterisk.</li>
* <li>Nach synchronem Laden mit FILE_NOT_FOUND: Dirty-State bleibt {@code false}.</li>
* <li>Nach synchronem Speichern mit Erfolg: Dirty-State zurückgesetzt.</li>
* <li>Nach {@code resetToDefault}: Textfeld enthält Default-Inhalt (nicht leer),
* Dirty-State ist {@code true} (Abweichung von geladener Baseline).</li>
* </ul>
*/
class GuiPromptEditorTabSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform muss innerhalb des Timeouts starten");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Gemeinsame Platform kein Platform.exit().
}
// =========================================================================
// Hilfsklassen
// =========================================================================
/** Synchroner Stub-Port: gibt vorbereitete Ergebnisse sofort zurück. */
private static class SyncPromptEditorPort implements GuiPromptEditorPort {
PromptLoadingResult loadResult = new PromptLoadingSuccess(
new PromptIdentifier("test-prompt.txt"), "Stub-Prompt-Inhalt");
PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/test-prompt.txt");
@Override
public PromptLoadingResult loadCurrentPrompt() {
return loadResult;
}
@Override
public PromptSaveResult save(String content) {
return saveResult;
}
@Override
public CorrectionOutcome createDefaultPromptIfMissing(
CorrectionSuggestion.CreatePromptFile suggestion) {
return new CorrectionOutcome.Applied(suggestion, "Stub-Prompt-Datei angelegt.");
}
}
/**
* Erstellt einen {@link GuiPromptEditorTab} mit synchronen Stubs:
* threadFactory führt den Runnable inline aus (vor worker.start()),
* fxDispatcher gibt den UI-Update-Runnable direkt weiter (kein Platform.runLater).
* Damit sind alle Operationen aus Testsicht vollständig synchron.
*/
private static GuiPromptEditorTab buildSyncTab(SyncPromptEditorPort port) {
GuiPromptEditorTab tab = new GuiPromptEditorTab(port, "/stub/test-prompt.txt", 60);
// Runnable wird inline ausgeführt; der zurückgegebene Thread startet leer (kein-op).
tab.threadFactory = runnable -> {
runnable.run(); // Synchron ausführen, inkl. fxDispatcher-Aufruf
return new Thread(); // Dummy-Thread; worker.start() beendet sofort
};
// UI-Updates synchron im selben Thread
tab.fxDispatcher = Runnable::run;
return tab;
}
// =========================================================================
// Tests
// =========================================================================
@Test
void tab_shouldBeCreatedWithTitlePrompt() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<Tab> tabRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
tabRef.set(editorTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
assertEquals("Prompt", tabRef.get().getText(), "Tab-Titel muss 'Prompt' sein");
}
@Test
void dirtyState_shouldBeFalse_afterConstruction() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
dirtyRef.set(editorTab.hasDirtyContent());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(), "Dirty-State muss nach Konstruktion false sein");
}
@Test
void dirtyState_shouldBeFalse_afterSuccessfulLoad() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
// Laden synchron auslösen (fxDispatcher = Runnable::run)
editorTab.loadPromptAsync();
dirtyRef.set(editorTab.hasDirtyContent());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(), "Dirty-State muss nach erfolgreichem Laden false sein");
}
@Test
void tabTitle_shouldNotContainAsterisk_afterSuccessfulLoad() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<String> titleRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
editorTab.loadPromptAsync();
titleRef.set(editorTab.tab().getText());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(titleRef.get().contains("*"),
"Tab-Titel darf nach erfolgreichem Laden keinen Asterisk enthalten");
}
@Test
void dirtyState_shouldBeFalse_whenLoadReturnsFileNotFound() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
port.loadResult = new PromptLoadingFailure("FILE_NOT_FOUND", "Datei nicht gefunden");
GuiPromptEditorTab editorTab = buildSyncTab(port);
editorTab.loadPromptAsync();
dirtyRef.set(editorTab.hasDirtyContent());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(), "Dirty-State muss false sein wenn Datei nicht gefunden wurde");
}
@Test
void notifyConfigurationChanged_shouldResetDirtyStateAndTitle() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
AtomicReference<String> titleRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
// Laden und anschliessend Inhalt aendern, um Dirty-State zu erzeugen
editorTab.loadPromptAsync();
editorTab.resetToDefault();
// Vorbedingung: Dirty-State muss aktiv sein
assertTrue(editorTab.hasDirtyContent(),
"Vorbedingung: Dirty-State muss nach resetToDefault aktiv sein");
// Konfiguration wechseln Dirty-State und Titel sollen zurueckgesetzt werden
editorTab.notifyConfigurationChanged(new SyncPromptEditorPort(), "/new/prompt.txt", 80);
dirtyRef.set(editorTab.hasDirtyContent());
titleRef.set(editorTab.tab().getText());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(),
"Dirty-State muss nach notifyConfigurationChanged false sein");
assertFalse(titleRef.get().contains("*"),
"Tab-Titel darf nach notifyConfigurationChanged keinen Asterisk enthalten; Titel war: "
+ titleRef.get());
}
@Test
void tabTitle_shouldContainAsterisk_afterEditWithLoadedBaseline() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<String> titleRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
editorTab.loadPromptAsync();
// Direkte TextArea-Manipulation simuliert Benutzer-Eingabe
// Über Reflection auf das private textArea-Feld zugreifen ist unerwünscht.
// Stattdessen: resetToDefault() setzt einen anderen Inhalt als den geladenen,
// was den Dirty-State auslöst.
editorTab.resetToDefault();
titleRef.set(editorTab.tab().getText());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
// Nach resetToDefault() wird der Default-Inhalt gesetzt.
// Falls dieser vom geladenen Inhalt abweicht, entsteht ein Dirty-State.
// Da Stub-Inhalt != Default-Template, muss Asterisk vorhanden sein.
assertTrue(titleRef.get().contains("*"),
"Tab-Titel muss nach Bearbeitung (resetToDefault) einen Asterisk enthalten; Titel war: "
+ titleRef.get());
}
@Test
void discardChanges_shouldResetDirtyStateAndTitle() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
AtomicReference<String> titleRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
editorTab.loadPromptAsync();
editorTab.resetToDefault();
// Vorbedingung: Dirty-State muss aktiv sein
assertTrue(editorTab.hasDirtyContent(),
"Vorbedingung: Dirty-State muss nach resetToDefault aktiv sein");
// Verwerfen simulieren
editorTab.discardChanges();
dirtyRef.set(editorTab.hasDirtyContent());
titleRef.set(editorTab.tab().getText());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(),
"Dirty-State muss nach discardChanges false sein");
assertFalse(titleRef.get().contains("*"),
"Tab-Titel darf nach discardChanges keinen Asterisk enthalten; Titel war: "
+ titleRef.get());
}
}
@@ -0,0 +1,343 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import javafx.application.Platform;
/**
* Tests für die Statuszeilen-Komponente {@link GuiStatusBar}.
* <p>
* Überprüft die Versionsanzeige, den Provider-Text und den Konfigurationspfad-Text
* in den verschiedenen Zuständen (ohne und mit geladener Konfiguration).
* <p>
* Die Tests laufen unter Monocle (Headless-JavaFX), da {@link GuiStatusBar} JavaFX-Controls erzeugt.
*/
class GuiStatusBarTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
/**
* Initialisiert die JavaFX-Plattform einmalig für alle Tests dieser Klasse.
*
* @throws InterruptedException falls der Thread beim Warten unterbrochen wird
*/
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch startLatch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
startLatch.countDown();
});
} catch (IllegalStateException alreadyInitialized) {
// JavaFX wurde bereits durch einen anderen Test gestartet
PLATFORM_STARTED.set(true);
startLatch.countDown();
}
assertTrue(
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX-Plattform muss innerhalb des Timeouts starten");
}
/** Plattform bleibt für nachfolgende Tests am Leben. */
@AfterAll
static void tearDownJavaFxPlatform() {
// Absichtlich kein Platform.exit() damit andere Smoke-Tests weiterhin die Plattform nutzen können.
}
// =========================================================================
// Versionsanzeige
// =========================================================================
/**
* Prüft, dass die Versionsanzeige das korrekte Präfix und die übergebene Version enthält.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void versionLabel_zeigtVersionMitPraefix() throws Exception {
AtomicReference<String> versionText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("3.0.42");
versionText.set(bar.versionText());
});
assertEquals("V3.0.42", versionText.get(),
"Die Versionsanzeige muss das Präfix 'V' gefolgt von der Versionsnummer enthalten");
}
/**
* Prüft, dass ein {@code null}-Wert für die Version als {@code "dev"} angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void versionLabel_mitNullFaellzurueckAufDev() throws Exception {
AtomicReference<String> versionText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar(null);
versionText.set(bar.versionText());
});
assertEquals("Vdev", versionText.get(),
"Ein null-Wert muss als Fallback 'dev' angezeigt werden");
}
/**
* Prüft, dass ein leerer String für die Version als {@code "dev"} angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void versionLabel_mitLeeremStringFaellzurueckAufDev() throws Exception {
AtomicReference<String> versionText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar(" ");
versionText.set(bar.versionText());
});
assertEquals("Vdev", versionText.get(),
"Ein leerer String muss als Fallback 'dev' angezeigt werden");
}
// =========================================================================
// Standardzustand ohne geladene Konfiguration
// =========================================================================
/**
* Prüft, dass Mitte und Rechts den Text Kein Profil geladen" zeigen, wenn keine
* Konfiguration geladen ist.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void ohneKonfiguration_zeigtKeinProfilGeladen() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
AtomicReference<String> configPathText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
providerText.set(bar.providerText());
configPathText.set(bar.configPathText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Provider-Text erscheinen");
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
}
/**
* Prüft, dass {@link GuiStatusBar#clearConfiguration()} Mitte und Rechts zurücksetzt.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void clearConfiguration_setztMitteUndRechtsZurueck() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
AtomicReference<String> configPathText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
// Zustand mit Konfiguration setzen, dann löschen
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
bar.applyEditorState(state);
bar.clearConfiguration();
providerText.set(bar.providerText());
configPathText.set(bar.configPathText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Nach clearConfiguration() muss 'Kein Profil geladen' als Provider-Text erscheinen");
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
"Nach clearConfiguration() muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
}
// =========================================================================
// Zustand nach Laden einer Konfiguration
// =========================================================================
/**
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Provider-Text
* mit Modell angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitClaudeUndModell_zeigtKorrektesFormat() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
bar.applyEditorState(state);
providerText.set(bar.providerText());
});
assertEquals("Provider: Claude · claude-opus-4-7", providerText.get(),
"Der Provider-Text muss das Format 'Provider: <Name> · <Modell>' haben");
}
/**
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Konfigurationspfad
* angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitKonfigurationspfad_zeigtKonfiguationspfad() throws Exception {
AtomicReference<String> configPathText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
bar.applyEditorState(state);
configPathText.set(bar.configPathText());
});
assertTrue(configPathText.get().contains("application.properties"),
"Der Konfigurationspfad muss den Dateinamen enthalten");
}
/**
* Prüft, dass ein OpenAI-kompatibler Provider korrekt angezeigt wird.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitOpenAiUndModell_zeigtKorrektesFormat() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
GuiConfigurationEditorState state = buildStateWithConfiguration(
"config/application.properties", AiProviderFamily.OPENAI_COMPATIBLE, "gpt-4o");
bar.applyEditorState(state);
providerText.set(bar.providerText());
});
assertEquals("Provider: OpenAI-kompatibel · gpt-4o", providerText.get(),
"Der Provider-Text muss für OpenAI-kompatibel den deutschen Anzeigenamen verwenden");
}
/**
* Prüft, dass beim Übergeben eines {@code null}-Zustands kein Absturz erfolgt und der
* Text Kein Profil geladen" erscheint.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_mitNull_keinAbsturz() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
bar.applyEditorState(null);
providerText.set(bar.providerText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Ein null-Zustand darf keinen Absturz verursachen");
}
/**
* Prüft, dass ohne geladenen Dateisnapshot Kein Profil geladen" angezeigt wird,
* auch wenn Konfigurationswerte vorhanden sind.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void applyEditorState_ohneSnapshot_zeigtKeinProfilGeladen() throws Exception {
AtomicReference<String> providerText = new AtomicReference<>();
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
// Standard-Template hat keinen Snapshot
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
bar.applyEditorState(state);
providerText.set(bar.providerText());
});
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
"Ohne geladenen Dateisnapshot muss 'Kein Profil geladen' erscheinen");
}
/**
* Prüft, dass der Wurzelknoten der Statuszeile nicht null ist.
*
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
*/
@Test
void root_istNichtNull() throws Exception {
AtomicBoolean rootNotNull = new AtomicBoolean(false);
runOnFxThread(() -> {
GuiStatusBar bar = new GuiStatusBar("1.0.0");
rootNotNull.set(bar.root() != null);
});
assertTrue(rootNotNull.get(), "Der Wurzelknoten der Statuszeile darf nicht null sein");
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
/**
* Führt eine Aktion synchron auf dem JavaFX Application Thread aus und wartet auf Abschluss.
*
* @param action die auszuführende Aktion
* @throws Exception falls die Aktion einen Fehler wirft oder das Timeout überschritten wird
*/
private static void runOnFxThread(Runnable action) throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try {
action.run();
} catch (Throwable t) {
error.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX-Thread-Task muss innerhalb des Timeouts abgeschlossen werden");
if (error.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", error.get());
}
}
/**
* Erstellt einen Editor-Zustand mit geladenem Dateisnapshot für den angegebenen
* Konfigurationspfad, Provider und Modell.
*
* @param configPath relativer Konfigurationsdateipfad
* @param family aktive Provider-Familie
* @param model Modellbezeichner
* @return ein Editor-Zustand mit Snapshot
*/
private static GuiConfigurationEditorState buildStateWithConfiguration(
String configPath, AiProviderFamily family, String model) {
GuiConfigurationEditorState template = GuiConfigurationTemplateFactory.createStandardTemplate();
// Provider und Modell setzen
GuiProviderConfigurationState providerState = new GuiProviderConfigurationState(
"https://api.example.com", model, "30",
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState.unresolved());
GuiConfigurationValues values = template.values()
.withActiveProviderFamily(family.getIdentifier())
.withProviderConfiguration(family, providerState);
// Snapshot anlegen
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
Path.of(configPath), new Properties());
return new GuiConfigurationEditorState(
Optional.of(snapshot), values, values, Optional.empty());
}
}
@@ -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");
});
}
@@ -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 -> { });
@@ -364,7 +367,8 @@ class GuiTechnicalTestCoordinatorSmokeTest {
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
new EditorConfigurationValidator(),
noOpPathCheckPort(),
noOpProviderService());
noOpProviderService(),
() -> java.util.Optional.empty());
EditorValidationInput blankInput = new EditorValidationInput(
"claude",
@@ -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"); }
@@ -323,7 +323,8 @@ class GuiValidateActionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
noOpApiKeyResolutionPort())),
noOpApiKeyResolutionPort()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -390,7 +391,8 @@ class GuiValidateActionSmokeTest {
},
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
noOpApiKeyResolutionPort())),
noOpApiKeyResolutionPort()),
() -> java.util.Optional.empty()),
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
@@ -0,0 +1,233 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Unit-Tests für {@link BatchRunSummaryBanner}.
* <p>
* Geprüft werden die Aggregationslogik und die Textgenerierung unabhängig von JavaFX.
* Die GUI-Integrationsmethoden ({@code clear()}, {@code update()}, {@code getNode()})
* erfordern eine JavaFX-Runtime und werden durch Smoke-Tests abgedeckt.
*/
class BatchRunSummaryBannerTest {
// -------------------------------------------------------------------------
// Hilfsmethoden für Testdaten
// -------------------------------------------------------------------------
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
return new GuiBatchRunResultRow(
"test.pdf",
new DocumentFingerprint("a".repeat(64)),
status,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
false,
Optional.empty());
}
private static GuiBatchRunResultRow resetPendingRow() {
GuiBatchRunResultRow base = row(DocumentCompletionStatus.SUCCESS);
return GuiBatchRunResultRow.resetMarker(base);
}
// -------------------------------------------------------------------------
// aggregateCounts
// -------------------------------------------------------------------------
@Test
void aggregateCounts_leereListe_alleZaehlerNull() {
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(Collections.emptyList());
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SUCCESS, 0));
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_RETRYABLE, 0));
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_PERMANENT, 0));
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0));
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0));
}
@Test
void aggregateCounts_nurErfolgreiche_zaehltNurSuccess() {
List<GuiBatchRunResultRow> rows = List.of(
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.SUCCESS));
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(rows);
assertEquals(3, counts.get(DocumentCompletionStatus.SUCCESS));
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
}
@Test
void aggregateCounts_gemischterLauf_alleKategorienKorrekt() {
List<GuiBatchRunResultRow> rows = List.of(
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.FAILED_RETRYABLE),
row(DocumentCompletionStatus.FAILED_PERMANENT),
row(DocumentCompletionStatus.FAILED_PERMANENT),
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(rows);
assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS));
assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
assertEquals(2, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
assertEquals(3, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
assertEquals(1, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
}
@Test
void aggregateCounts_resetPendingZeilenWerdenNichtGezaehlt() {
// Reset-Pending-Zeilen haben noch keinen abgeschlossenen Status und
// dürfen nicht ins Summary einfließen
List<GuiBatchRunResultRow> rows = List.of(
row(DocumentCompletionStatus.SUCCESS),
resetPendingRow(),
resetPendingRow());
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(rows);
assertEquals(1, counts.get(DocumentCompletionStatus.SUCCESS));
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
}
// -------------------------------------------------------------------------
// buildBannerText
// -------------------------------------------------------------------------
@Test
void buildBannerText_alleZaehlerNull_leerString() {
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 0,
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
DocumentCompletionStatus.FAILED_PERMANENT, 0,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
String text = BatchRunSummaryBanner.buildBannerText(counts);
assertTrue(text.isEmpty(), "Leere Zähler ergeben leeren Text: '" + text + "'");
}
@Test
void buildBannerText_nurErfolgreiche_nurSuccessSegment() {
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 17,
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
DocumentCompletionStatus.FAILED_PERMANENT, 0,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
String text = BatchRunSummaryBanner.buildBannerText(counts);
assertTrue(text.contains("17"), "Anzahl 17 muss im Text erscheinen: " + text);
assertTrue(text.contains("erfolgreich"), "Kategorie 'erfolgreich' muss erscheinen: " + text);
assertTrue(text.contains(""), "Icon ✓ muss erscheinen: " + text);
assertFalse(text.contains(""), "Kein ↻ wenn FAILED_RETRYABLE = 0: " + text);
assertFalse(text.contains("×"), "Kein × wenn FAILED_PERMANENT = 0: " + text);
assertFalse(text.contains(""), "Kein ≡ wenn SKIPPED_ALREADY_PROCESSED = 0: " + text);
assertFalse(text.contains(""), "Kein ⊘ wenn SKIPPED_FINAL_FAILURE = 0: " + text);
}
@Test
void buildBannerText_vollerLauf_alleSegmenteEnthalten() {
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 14,
DocumentCompletionStatus.FAILED_RETRYABLE, 1,
DocumentCompletionStatus.FAILED_PERMANENT, 2,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 3,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 1);
String text = BatchRunSummaryBanner.buildBannerText(counts);
// Jedes Segment enthält Icon + Anzahl + Kategorie
assertTrue(text.contains("✓ 14 erfolgreich"), "SUCCESS-Segment: " + text);
assertTrue(text.contains("↻ 1 wird wiederholt"), "FAILED_RETRYABLE-Segment: " + text);
assertTrue(text.contains("× 2 fehlgeschlagen"), "FAILED_PERMANENT-Segment: " + text);
assertTrue(text.contains("≡ 3 übersprungen"), "SKIPPED_ALREADY_PROCESSED-Segment: " + text);
assertTrue(text.contains("⊘ 1 endgültig übersprungen"), "SKIPPED_FINAL_FAILURE-Segment: " + text);
}
@Test
void buildBannerText_nurSkippedFinalFailure_erscheintImBanner() {
// Sicherstellung: erscheint auch wenn > 0, obwohl es die seltenste Kategorie ist
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 0,
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
DocumentCompletionStatus.FAILED_PERMANENT, 0,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 2);
String text = BatchRunSummaryBanner.buildBannerText(counts);
assertTrue(text.contains(""), "Icon ⊘ muss erscheinen: " + text);
assertTrue(text.contains("2"), "Anzahl 2 muss erscheinen: " + text);
assertTrue(text.contains("endgültig übersprungen"), "Kategorie muss erscheinen: " + text);
}
@Test
void buildBannerText_nurKategorienMitAnzahlGroesserNull_erscheinen() {
// Nur SUCCESS=5 ist gesetzt; alle anderen 0 kein anderes Segment
Map<DocumentCompletionStatus, Integer> counts = Map.of(
DocumentCompletionStatus.SUCCESS, 5,
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
DocumentCompletionStatus.FAILED_PERMANENT, 0,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
String text = BatchRunSummaryBanner.buildBannerText(counts);
// Kein Trennzeichen (·) darf erscheinen, wenn nur ein Segment vorhanden ist
assertFalse(text.contains("·"), "Kein Trenner bei einzelnem Segment: " + text);
assertTrue(text.contains("✓ 5 erfolgreich"), "Nur SUCCESS-Segment: " + text);
}
@Test
void aggregateCounts_kombinationMitResetPending_nurEchtAbgeschlosseneGezaehlt() {
// 2 SUCCESS + 1 FAILED_PERMANENT + 1 resetPending(SUCCESS) nur 2+1 gezählt
List<GuiBatchRunResultRow> rows = List.of(
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.SUCCESS),
row(DocumentCompletionStatus.FAILED_PERMANENT),
resetPendingRow());
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(rows);
assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS));
assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
// Summe aller gezählten Einträge = 3, nicht 4
int total = counts.values().stream().mapToInt(Integer::intValue).sum();
assertEquals(3, total, "Reset-Pending darf nicht mitgezählt werden");
}
}
@@ -287,10 +287,10 @@ class GuiBatchRunCoordinatorTest {
@Test
void resultRowIcons_matchSpecification() {
assertEquals("\u2714", row(DocumentCompletionStatus.SUCCESS).statusIcon());
assertEquals("\u26A0", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
assertEquals("\u2718", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
assertEquals("\u23ED", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
assertEquals("", row(DocumentCompletionStatus.SUCCESS).statusIcon());
assertEquals("", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
assertEquals("×", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
assertEquals("", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
}
@Test
@@ -21,8 +21,7 @@ class GuiBatchRunResultRowTest {
private static final DocumentFingerprint FP =
new DocumentFingerprint("a".repeat(64));
private static final DocumentFingerprint FP2 =
new DocumentFingerprint("b".repeat(64));
// -------------------------------------------------------------------------
// Basic construction
@@ -95,27 +94,27 @@ class GuiBatchRunResultRowTest {
@Test
void statusIcon_success_isCheckMark() {
assertEquals("\u2714", row(DocumentCompletionStatus.SUCCESS).statusIcon());
assertEquals("", row(DocumentCompletionStatus.SUCCESS).statusIcon());
}
@Test
void statusIcon_failedRetryable_isWarning() {
assertEquals("\u26A0", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
assertEquals("", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
}
@Test
void statusIcon_failedPermanent_isBallotX() {
assertEquals("\u2718", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
assertEquals("×", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
}
@Test
void statusIcon_skippedAlreadyProcessed_isNextTrack() {
assertEquals("\u23ED", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
assertEquals("", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
}
@Test
void statusIcon_skippedFinalFailure_isCircledDivisionSlash() {
assertEquals("\u2298", row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon());
assertEquals("", row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon());
}
@Test
@@ -189,6 +188,50 @@ class GuiBatchRunResultRowTest {
}
}
// -------------------------------------------------------------------------
// statusTooltip
// -------------------------------------------------------------------------
@Test
void statusTooltip_success_isNonBlank() {
assertFalse(row(DocumentCompletionStatus.SUCCESS).statusTooltip().isBlank());
}
@Test
void statusTooltip_failedRetryable_isNonBlank() {
assertFalse(row(DocumentCompletionStatus.FAILED_RETRYABLE).statusTooltip().isBlank());
}
@Test
void statusTooltip_failedPermanent_isNonBlank() {
assertFalse(row(DocumentCompletionStatus.FAILED_PERMANENT).statusTooltip().isBlank());
}
@Test
void statusTooltip_failedRetryable_and_failedPermanent_areDifferent() {
String retryable = row(DocumentCompletionStatus.FAILED_RETRYABLE).statusTooltip();
String permanent = row(DocumentCompletionStatus.FAILED_PERMANENT).statusTooltip();
assertFalse(retryable.equals(permanent),
"FAILED_RETRYABLE und FAILED_PERMANENT müssen unterschiedliche Tooltips haben");
}
@Test
void statusTooltip_allStatuses_delegatesToProcessingStatusPresentation() {
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
String rowTooltip = row(status).statusTooltip();
String expectedTooltip = ProcessingStatusPresentation.tooltipFor(status);
assertEquals(expectedTooltip, rowTooltip,
"statusTooltip() soll Wert von ProcessingStatusPresentation liefern für " + status);
}
}
@Test
void statusTooltip_resetPending_returnsResetLabel() {
GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(
row(DocumentCompletionStatus.SUCCESS));
assertEquals(GuiBatchRunResultRow.RESET_PENDING_LABEL, marker.statusTooltip());
}
// -------------------------------------------------------------------------
// aiFailureMessage
// -------------------------------------------------------------------------
@@ -140,10 +140,10 @@ class GuiBatchRunTabSmokeTest {
tab().resultTable().getSelectionModel().select(1);
assertTrue(tab().detailArea().getText().contains(GuiBatchRunTab.NO_REASONING_TEXT));
// SKIPPED_ALREADY_PROCESSED muss das Weiterspulen-Icon tragen, nicht .
// SKIPPED_ALREADY_PROCESSED trägt das Identisch-Icon , nicht .
GuiBatchRunResultRow skippedRow = tab().resultTable().getItems().get(2);
assertEquals(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, skippedRow.status());
assertEquals("\u23ED", skippedRow.statusIcon());
assertEquals("", skippedRow.statusIcon());
});
}
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -0,0 +1,272 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation.StatusVisuals;
/**
* Unit-Tests für {@link ProcessingStatusPresentation}.
* <p>
* Prüft, dass alle {@link DocumentCompletionStatus}-Werte korrekte Icons, Farben,
* Tooltip-Texte und Summary-Kategorielabels liefern und dass keine zwei Status
* dasselbe Icon teilen.
*/
class ProcessingStatusPresentationTest {
// -------------------------------------------------------------------------
// iconFor
// -------------------------------------------------------------------------
@Test
void iconFor_success_isCheckMark() {
assertEquals(ProcessingStatusPresentation.ICON_SUCCESS,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SUCCESS));
}
@Test
void iconFor_failedRetryable_isClockwiseArrow() {
assertEquals(ProcessingStatusPresentation.ICON_FAILED_RETRYABLE,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE));
}
@Test
void iconFor_failedPermanent_isMultiplicationSign() {
assertEquals(ProcessingStatusPresentation.ICON_FAILED_PERMANENT,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT));
}
@Test
void iconFor_skippedAlreadyProcessed_isIdenticalTo() {
assertEquals(ProcessingStatusPresentation.ICON_SKIPPED_ALREADY_PROCESSED,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
}
@Test
void iconFor_skippedFinalFailure_isCircledDivisionSlash() {
assertEquals(ProcessingStatusPresentation.ICON_SKIPPED_FINAL_FAILURE,
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
}
@Test
void iconFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.iconFor(null));
}
@Test
void icons_areAllDistinct() {
Set<String> icons = new HashSet<>();
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
icons.add(ProcessingStatusPresentation.iconFor(status));
}
assertEquals(DocumentCompletionStatus.values().length, icons.size(),
"Jeder Status muss ein eindeutiges Icon haben");
}
// -------------------------------------------------------------------------
// cssColorFor
// -------------------------------------------------------------------------
@ParameterizedTest
@EnumSource(DocumentCompletionStatus.class)
void cssColorFor_allStatuses_returnsNonBlankHexColor(DocumentCompletionStatus status) {
String color = ProcessingStatusPresentation.cssColorFor(status);
assertAll(
() -> assertNotNull(color, "Farbe darf nicht null sein für " + status),
() -> assertFalse(color.isBlank(), "Farbe darf nicht leer sein für " + status),
() -> assertFalse(color.isEmpty(), "Farbe darf nicht leer sein für " + status)
);
// Farbe muss im CSS-Hex-Format beginnen (#)
assertFalse(color.isBlank());
assertEquals('#', color.charAt(0), "CSS-Farbe muss mit # beginnen für " + status);
}
@Test
void cssColorFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.cssColorFor(null));
}
@Test
void failedRetryable_and_failedPermanent_haveDifferentColors() {
String orangeColor = ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_RETRYABLE);
String redColor = ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_PERMANENT);
assertFalse(orangeColor.equals(redColor),
"FAILED_RETRYABLE (Orange) und FAILED_PERMANENT (Rot) müssen unterschiedliche Farben haben");
}
// -------------------------------------------------------------------------
// tooltipFor
// -------------------------------------------------------------------------
@ParameterizedTest
@EnumSource(DocumentCompletionStatus.class)
void tooltipFor_allStatuses_returnsNonBlankText(DocumentCompletionStatus status) {
String tooltip = ProcessingStatusPresentation.tooltipFor(status);
assertNotNull(tooltip, "Tooltip darf nicht null sein für " + status);
assertFalse(tooltip.isBlank(), "Tooltip darf nicht leer sein für " + status);
}
@Test
void tooltipFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.tooltipFor(null));
}
@Test
void tooltipFor_failedRetryable_containsWiederholung() {
String tooltip = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_RETRYABLE);
assertFalse(tooltip.isBlank());
// Tooltip muss die Retry-Semantik kommunizieren
assertFalse(tooltip.equals(ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT)),
"FAILED_RETRYABLE und FAILED_PERMANENT müssen unterschiedliche Tooltips haben");
}
@Test
void tooltipFor_failedPermanent_containsKeinWeitererVersuch() {
String tooltip = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT);
// Tooltip für FAILED_PERMANENT muss kommunizieren, dass kein weiterer automatischer Versuch folgt
assertFalse(tooltip.isBlank());
}
@Test
void tooltips_areAllDistinct() {
Set<String> tooltips = new HashSet<>();
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
tooltips.add(ProcessingStatusPresentation.tooltipFor(status));
}
assertEquals(DocumentCompletionStatus.values().length, tooltips.size(),
"Jeder Status muss einen eindeutigen Tooltip haben");
}
// -------------------------------------------------------------------------
// summaryCategoryFor
// -------------------------------------------------------------------------
@ParameterizedTest
@EnumSource(DocumentCompletionStatus.class)
void summaryCategoryFor_allStatuses_returnsNonBlankLabel(DocumentCompletionStatus status) {
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
assertNotNull(category, "Summary-Kategorie darf nicht null sein für " + status);
assertFalse(category.isBlank(), "Summary-Kategorie darf nicht leer sein für " + status);
}
@Test
void summaryCategoryFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.summaryCategoryFor(null));
}
// -------------------------------------------------------------------------
// visualsFor (gebündelt)
// -------------------------------------------------------------------------
@ParameterizedTest
@EnumSource(DocumentCompletionStatus.class)
void visualsFor_allStatuses_returnsConsistentRecord(DocumentCompletionStatus status) {
StatusVisuals visuals = ProcessingStatusPresentation.visualsFor(status);
assertAll(
() -> assertNotNull(visuals, "StatusVisuals darf nicht null sein für " + status),
() -> assertEquals(ProcessingStatusPresentation.iconFor(status), visuals.icon()),
() -> assertEquals(ProcessingStatusPresentation.cssColorFor(status), visuals.cssColor()),
() -> assertEquals(ProcessingStatusPresentation.tooltipFor(status), visuals.tooltipText()),
() -> assertEquals(ProcessingStatusPresentation.summaryCategoryFor(status),
visuals.summaryCategoryLabel())
);
}
@Test
void visualsFor_null_throws() {
assertThrows(NullPointerException.class,
() -> ProcessingStatusPresentation.visualsFor(null));
}
// -------------------------------------------------------------------------
// Spezifische Status-Mapping-Werte (gemäß Spezifikation)
// -------------------------------------------------------------------------
@Test
void success_mapping_correctValues() {
assertAll(
() -> assertEquals("", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SUCCESS)),
() -> assertEquals("#2e7d32", ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.SUCCESS)),
() -> assertEquals("erfolgreich",
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.SUCCESS))
);
}
@Test
void failedRetryable_mapping_correctValues() {
assertAll(
() -> assertEquals("", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE)),
() -> assertEquals("#d98200",
ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_RETRYABLE)),
() -> assertEquals("wird wiederholt",
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.FAILED_RETRYABLE))
);
}
@Test
void failedPermanent_mapping_correctValues() {
assertAll(
() -> assertEquals("×", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT)),
() -> assertEquals("#c62828",
ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_PERMANENT)),
() -> assertEquals("fehlgeschlagen",
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.FAILED_PERMANENT))
);
}
@Test
void skippedAlreadyProcessed_mapping_correctValues() {
assertAll(
() -> assertEquals("",
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED)),
() -> assertEquals("übersprungen",
ProcessingStatusPresentation.summaryCategoryFor(
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED))
);
}
@Test
void skippedFinalFailure_mapping_correctValues() {
assertAll(
() -> assertEquals("",
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE)),
() -> assertEquals("endgültig übersprungen",
ProcessingStatusPresentation.summaryCategoryFor(
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE))
);
}
// -------------------------------------------------------------------------
// Farbe ist NICHT einziges Unterscheidungsmerkmal
// -------------------------------------------------------------------------
@Test
void failedRetryable_and_failedPermanent_distinctByIconAndTooltip() {
String iconRetryable = ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE);
String iconPermanent = ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT);
String tooltipRetryable = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_RETRYABLE);
String tooltipPermanent = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT);
assertAll(
() -> assertFalse(iconRetryable.equals(iconPermanent),
"Icons müssen sich unterscheiden"),
() -> assertFalse(tooltipRetryable.equals(tooltipPermanent),
"Tooltips müssen sich unterscheiden")
);
}
}
+6 -3
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-out</artifactId>
<packaging>jar</packaging>
@@ -31,6 +31,10 @@
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
@@ -48,8 +52,7 @@
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>${log4j.version}</version>
<artifactId>log4j-slf4j2-impl</artifactId>
<scope>test</scope>
</dependency>
<dependency>
@@ -1,6 +1,8 @@
package de.gecheckt.pdf.umbenenner.adapter.out.fingerprint;
import java.io.IOException;
import java.io.InputStream;
import java.security.DigestInputStream;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
@@ -45,7 +47,7 @@ public class Sha256FingerprintAdapter implements FingerprintPort {
* The implementation:
* <ol>
* <li>Resolves the opaque locator to a filesystem path</li>
* <li>Reads the entire file content</li>
* <li>Reads the file content in chunks via a streaming digest</li>
* <li>Applies SHA-256 hashing</li>
* <li>Returns the hex-encoded result wrapped in a {@link FingerprintSuccess}</li>
* </ol>
@@ -113,8 +115,9 @@ public class Sha256FingerprintAdapter implements FingerprintPort {
/**
* Computes the SHA-256 hash of the file content at the given path.
* <p>
* Reads the entire file content and applies SHA-256 hashing to produce
* a lowercase hexadecimal representation of the digest.
* Liest die Datei blockweise über einen {@link DigestInputStream}, um den Heap-Bedarf
* bei großen PDFs zu minimieren. Das erzeugte Hash-Ergebnis ist bitidentisch zur
* byteweisen Verarbeitung des gesamten Dateiinhalts.
*
* @param filePath the path to the file to hash; must not be null
* @return the lowercase hexadecimal representation of the SHA-256 digest (64 characters)
@@ -123,8 +126,16 @@ public class Sha256FingerprintAdapter implements FingerprintPort {
*/
private String computeSha256Hash(Path filePath) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] fileBytes = Files.readAllBytes(filePath);
byte[] hashBytes = digest.digest(fileBytes);
// Streaming-Verarbeitung: Die Datei wird in 8-KB-Blöcken gelesen, damit auch
// sehr große PDFs nicht vollständig in den Heap geladen werden müssen.
byte[] buf = new byte[8192];
try (InputStream is = Files.newInputStream(filePath);
DigestInputStream dis = new DigestInputStream(is, digest)) {
while (dis.read(buf) != -1) {
// DigestInputStream leitet jeden Block automatisch an den MessageDigest weiter
}
}
byte[] hashBytes = digest.digest();
return bytesToHex(hashBytes);
}
@@ -2,9 +2,12 @@ package de.gecheckt.pdf.umbenenner.adapter.out.prompt;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
import java.util.UUID;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -13,28 +16,36 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
/**
* Filesystem-based implementation of {@link PromptPort}.
* Dateisystembasierte Implementierung von {@link PromptPort}.
* <p>
* Loads prompt templates from an external file on disk and derives a stable identifier
* from the filename. Ensures that empty or technically unusable prompts are rejected.
* Lädt Prompt-Templates aus einer externen Datei auf dem Datenträger und leitet einen
* stabilen Identifikator aus dem Dateinamen ab. Stellt sicher, dass leere oder technisch
* unbrauchbare Prompts abgelehnt werden.
* <p>
* <strong>Identifier derivation:</strong>
* The stable prompt identifier is derived from the filename of the prompt file.
* This ensures deterministic, reproducible identification across batch runs.
* For example, a prompt file named {@code "prompt_de_v2.txt"} receives the identifier
* <strong>Identifikatorableitung:</strong>
* Der stabile Identifikator wird aus dem Dateinamen der Prompt-Datei abgeleitet.
* Eine Prompt-Datei namens {@code "prompt_de_v2.txt"} erhält den Identifikator
* {@code "prompt_de_v2.txt"}.
* <p>
* <strong>Content validation:</strong>
* After loading, the prompt content is trimmed and validated to ensure it is not empty.
* An empty prompt (or one containing only whitespace) is considered technically unusable
* and results in a {@link PromptLoadingFailure}.
* <strong>Inhaltsprüfung:</strong>
* Nach dem Laden wird der Inhalt getrimmt und auf Leerheit geprüft. Ein leerer Prompt
* (oder einer, der nur Leerzeichen enthält) gilt als technisch unbrauchbar und führt zu
* {@link PromptLoadingFailure}.
* <p>
* <strong>Error handling:</strong>
* All technical failures (file not found, I/O errors, permission issues) are caught
* and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
* <strong>Atomares Speichern:</strong>
* {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei <em>im selben
* Verzeichnis</em> wie die Zieldatei und verschiebt diese danach atomar via
* {@code ATOMIC_MOVE}. Bei einem Fehler beim atomaren Verschieben wird kein stiller
* Fallback auf nicht-atomares Schreiben durchgeführt.
* <p>
* <strong>Fehlerbehandlung:</strong>
* Alle technischen Fehler (Datei nicht gefunden, I/O-Fehler, fehlende Berechtigungen)
* werden abgefangen und als strukturierte Ergebnistypen zurückgegeben keine Exceptions
* werden propagiert.
*/
public class FilesystemPromptPortAdapter implements PromptPort {
@@ -43,15 +54,21 @@ public class FilesystemPromptPortAdapter implements PromptPort {
private final Path promptFilePath;
/**
* Creates the adapter with the configured prompt file path.
* Erstellt den Adapter mit dem konfigurierten Pfad zur Prompt-Datei.
*
* @param promptFilePath the path to the prompt template file; must not be null
* @throws NullPointerException if promptFilePath is null
* @param promptFilePath Pfad zur Prompt-Template-Datei; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code promptFilePath} null ist
*/
public FilesystemPromptPortAdapter(Path promptFilePath) {
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
}
/**
* Lädt das konfigurierte Prompt-Template aus der Datei.
*
* @return {@link PromptLoadingResult} mit dem geladenen Inhalt oder einem klassifizierten Fehler;
* nie {@code null}
*/
@Override
public PromptLoadingResult loadPrompt() {
try {
@@ -71,11 +88,11 @@ public class FilesystemPromptPortAdapter implements PromptPort {
}
PromptIdentifier identifier = deriveIdentifier();
LOG.debug("Prompt loaded successfully from {}", promptFilePath);
LOG.debug("Prompt erfolgreich geladen von {}", promptFilePath);
return new PromptLoadingSuccess(identifier, trimmedContent);
} catch (IOException e) {
LOG.error("Failed to load prompt file: {}", promptFilePath, e);
LOG.error("Fehler beim Laden der Prompt-Datei: {}", promptFilePath, e);
return new PromptLoadingFailure(
"IO_ERROR",
"Failed to read prompt file: " + e.getMessage());
@@ -83,15 +100,88 @@ public class FilesystemPromptPortAdapter implements PromptPort {
}
/**
* Derives a stable prompt identifier from the filename.
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
* <p>
* The identifier is simply the filename (without the directory path).
* This ensures that the same prompt file always receives the same identifier.
* Der Ablauf:
* <ol>
* <li>Prüfen, ob der Zielordner existiert.</li>
* <li>Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen.</li>
* <li>Inhalt in UTF-8 in die temporäre Datei schreiben.</li>
* <li>Temporäre Datei via {@code ATOMIC_MOVE} zur Zieldatei verschieben.</li>
* <li>Bei Fehler: temporäre Datei aufräumen, Fehler als Ergebnis zurückgeben.</li>
* </ol>
* <p>
* Zeilenenden werden unverändert übernommen. Es findet keine Normalisierung statt.
*
* @return a stable PromptIdentifier based on the filename
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @throws NullPointerException wenn {@code content} null ist
*/
@Override
public PromptSaveResult savePrompt(String content) {
Objects.requireNonNull(content, "content must not be null");
Path targetDir = promptFilePath.getParent();
if (targetDir == null || !Files.isDirectory(targetDir)) {
String message = "Zielordner der Prompt-Datei existiert nicht: "
+ (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
LOG.warn("Prompt speichern fehlgeschlagen: {}", message);
return new PromptSaveResult.TargetDirectoryMissing(message);
}
// Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen
// (nicht im System-Temp ATOMIC_MOVE funktioniert nicht zuverlässig über Dateisystem-Grenzen)
Path tempFile = targetDir.resolve(".prompt-tmp-" + UUID.randomUUID() + ".tmp");
try {
Files.write(tempFile, content.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
beräumeTempDatei(tempFile);
String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
return new PromptSaveResult.WriteFailed(message, e);
}
// Atomares Verschieben kein stiller Fallback auf nicht-atomares Move
try {
Files.move(tempFile, promptFilePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
LOG.info("Prompt-Datei erfolgreich gespeichert: {}", promptFilePath.toAbsolutePath());
return new PromptSaveResult.Saved(promptFilePath.toAbsolutePath().toString());
} catch (AtomicMoveNotSupportedException e) {
beräumeTempDatei(tempFile);
String message = "Atomares Verschieben der Prompt-Datei wird vom Dateisystem nicht unterstützt: " + e.getMessage();
LOG.warn("Prompt speichern fehlgeschlagen (kein Fallback): {}", message, e);
return new PromptSaveResult.AtomicMoveFailed(message);
} catch (IOException e) {
beräumeTempDatei(tempFile);
String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
return new PromptSaveResult.AtomicMoveFailed(message);
}
}
/**
* Leitet den stabilen Prompt-Identifikator aus dem Dateinamen ab.
* <p>
* Der Identifikator entspricht dem Dateinamen ohne Verzeichnispfad.
*
* @return stabiler {@link PromptIdentifier} basierend auf dem Dateinamen
*/
private PromptIdentifier deriveIdentifier() {
String filename = promptFilePath.getFileName().toString();
return new PromptIdentifier(filename);
}
/**
* Versucht, die temporäre Datei zu löschen. Fehler werden nur geloggt.
*
* @param tempFile die zu löschende temporäre Datei
*/
private void beräumeTempDatei(Path tempFile) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ex) {
LOG.warn("Temporäre Prompt-Datei konnte nicht gelöscht werden: {}", tempFile, ex);
}
}
}
@@ -0,0 +1,409 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
* SQLite-Implementierung von {@link HistoryQueryPort}.
* <p>
* Kapselt alle lesenden Datenbankoperationen für den Historien-Tab.
* Sämtliche JDBC-Details sind strikt in dieser Klasse eingeschlossen;
* keine JDBC-Typen erscheinen im Port-Interface oder in Domänen-/Application-Typen.
* <p>
* <strong>Suche:</strong> Freitextsuche ist case-insensitiv (via {@code LOWER()}).
* Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem
* SQL-LIKE-Aufruf mit {@code \} escaped.
* <p>
* <strong>Sortierung:</strong> Standard absteigend nach {@code updated_at},
* Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar).
* <p>
* <strong>Limit:</strong> Wird direkt als SQL-{@code LIMIT} angewendet.
* Ein Limit von 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr
* als 500 Treffer vorhanden sind.
*/
public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
private static final Logger logger = LogManager.getLogger(SqliteHistoryQueryAdapter.class);
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
private final String jdbcUrl;
/**
* Erzeugt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
*
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
* @throws NullPointerException wenn {@code jdbcUrl} null ist
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
*/
public SqliteHistoryQueryAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
}
this.jdbcUrl = jdbcUrl;
}
/**
* {@inheritDoc}
* <p>
* Die SQL-Abfrage aggregiert die Versuchsanzahl per {@code COUNT}-Subquery.
* Freitextsuche und Status-Filter werden als optionale WHERE-Klauseln ergänzt.
*
* @param query Abfrageparameter; darf nicht {@code null} sein
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
Objects.requireNonNull(query, "query darf nicht null sein");
StringBuilder sql = new StringBuilder("""
SELECT
dr.fingerprint,
dr.overall_status,
dr.last_known_source_file_name,
dr.last_target_file_name,
dr.last_known_source_locator,
dr.updated_at,
(SELECT COUNT(*) FROM processing_attempt pa WHERE pa.fingerprint = dr.fingerprint) AS attempt_count
FROM document_record dr
WHERE 1=1
""");
List<Object> params = new ArrayList<>();
// Freitextsuche: case-insensitiv über Quelldateiname und Zieldateiname
String searchText = query.searchText();
if (searchText != null && !searchText.isBlank()) {
String escaped = escapeSqlLike(searchText.strip().toLowerCase());
sql.append(" AND (LOWER(dr.last_known_source_file_name) LIKE ? ESCAPE '\\' "
+ "OR LOWER(dr.last_target_file_name) LIKE ? ESCAPE '\\')");
String pattern = "%" + escaped + "%";
params.add(pattern);
params.add(pattern);
}
// Status-Filter
String statusFilter = query.statusFilter();
if (statusFilter != null && !statusFilter.isBlank()) {
sql.append(" AND dr.overall_status = ?");
params.add(statusFilter.strip());
}
sql.append(" ORDER BY dr.updated_at DESC, dr.fingerprint ASC");
sql.append(" LIMIT ?");
params.add(query.limit());
try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement();
PreparedStatement stmt = connection.prepareStatement(sql.toString())) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
for (int i = 0; i < params.size(); i++) {
stmt.setObject(i + 1, params.get(i));
}
try (ResultSet rs = stmt.executeQuery()) {
List<DocumentHistoryRow> rows = new ArrayList<>();
while (rs.next()) {
rows.add(mapToDocumentHistoryRow(rs));
}
logger.debug("Historien-Übersicht geladen: {} Zeilen (Limit {})", rows.size(), query.limit());
return List.copyOf(rows);
}
} catch (SQLException e) {
String message = "Historien-Übersicht konnte nicht geladen werden: " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* {@inheritDoc}
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
String sql = """
SELECT
last_known_source_locator,
last_known_source_file_name,
overall_status,
content_error_count,
transient_error_count,
last_failure_instant,
last_success_instant,
created_at,
updated_at,
last_target_path,
last_target_file_name
FROM document_record
WHERE fingerprint = ?
""";
try (Connection connection = getConnection();
PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapToDocumentRecord(rs, fingerprint));
}
return Optional.empty();
}
} catch (SQLException e) {
String message = "Dokument-Stammsatz konnte nicht geladen werden für Fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* {@inheritDoc}
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return unveränderliche Liste der Versuche aufsteigend nach {@code attempt_number};
* nie {@code null}; kann leer sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
String sql = """
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
WHERE fingerprint = ?
ORDER BY attempt_number ASC
""";
try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement();
PreparedStatement stmt = connection.prepareStatement(sql)) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
stmt.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = stmt.executeQuery()) {
List<ProcessingAttempt> attempts = new ArrayList<>();
while (rs.next()) {
attempts.add(mapToProcessingAttempt(rs));
}
return List.copyOf(attempts);
}
} catch (SQLException e) {
String message = "Verarbeitungsversuche konnten nicht geladen werden für Fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
// -------------------------------------------------------------------------
// Mapping-Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Bildet eine ResultSet-Zeile auf eine {@link DocumentHistoryRow} ab.
*
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
* @return die gemappte Zeile; nie {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private DocumentHistoryRow mapToDocumentHistoryRow(ResultSet rs) throws SQLException {
String fpHex = rs.getString("fingerprint");
String statusStr = rs.getString("overall_status");
String sourceFileName = rs.getString("last_known_source_file_name");
String targetFileName = rs.getString("last_target_file_name"); // nullable
String sourcePath = rs.getString("last_known_source_locator");
String updatedAtStr = rs.getString("updated_at");
long attemptCount = rs.getLong("attempt_count");
return new DocumentHistoryRow(
new DocumentFingerprint(fpHex),
ProcessingStatus.valueOf(statusStr),
sourceFileName,
targetFileName,
sourcePath,
stringToInstant(updatedAtStr),
attemptCount);
}
/**
* Bildet eine ResultSet-Zeile auf einen {@link DocumentRecord} ab.
*
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
* @param fingerprint der Fingerprint, der bereits bekannt ist
* @return der gemappte Stammsatz; nie {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private DocumentRecord mapToDocumentRecord(ResultSet rs, DocumentFingerprint fingerprint) throws SQLException {
return new DocumentRecord(
fingerprint,
new SourceDocumentLocator(rs.getString("last_known_source_locator")),
rs.getString("last_known_source_file_name"),
ProcessingStatus.valueOf(rs.getString("overall_status")),
new FailureCounters(
rs.getInt("content_error_count"),
rs.getInt("transient_error_count")),
stringToInstant(rs.getString("last_failure_instant")),
stringToInstant(rs.getString("last_success_instant")),
stringToInstant(rs.getString("created_at")),
stringToInstant(rs.getString("updated_at")),
rs.getString("last_target_path"),
rs.getString("last_target_file_name"));
}
/**
* Bildet eine ResultSet-Zeile auf einen {@link ProcessingAttempt} ab.
*
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
* @return der gemappte Versuch; nie {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private ProcessingAttempt mapToProcessingAttempt(ResultSet rs) throws SQLException {
String resolvedDateStr = rs.getString("resolved_date");
LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;
String dateSourceStr = rs.getString("date_source");
DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;
int processedPageCountRaw = rs.getInt("processed_page_count");
Integer processedPageCount = rs.wasNull() ? null : processedPageCountRaw;
int sentCharacterCountRaw = rs.getInt("sent_character_count");
Integer sentCharacterCount = rs.wasNull() ? null : sentCharacterCountRaw;
return new ProcessingAttempt(
new DocumentFingerprint(rs.getString("fingerprint")),
new RunId(rs.getString("run_id")),
rs.getInt("attempt_number"),
stringToInstant(rs.getString("started_at")),
stringToInstant(rs.getString("ended_at")),
ProcessingStatus.valueOf(rs.getString("status")),
rs.getString("failure_class"),
rs.getString("failure_message"),
rs.getBoolean("retryable"),
rs.getString("ai_provider"),
rs.getString("model_name"),
rs.getString("prompt_identifier"),
processedPageCount,
sentCharacterCount,
rs.getString("ai_raw_response"),
rs.getString("ai_reasoning"),
resolvedDate,
dateSource,
rs.getString("validated_title"),
rs.getString("final_target_file_name"));
}
// -------------------------------------------------------------------------
// SQL-LIKE Escaping
// -------------------------------------------------------------------------
/**
* Escaped Sonderzeichen {@code %} und {@code _} in einer LIKE-Eingabe mit {@code \}.
* <p>
* Der Escape-Charakter {@code \} muss in der SQL-Abfrage als
* {@code ESCAPE '\'} angegeben werden.
*
* @param input die rohe Benutzereingabe; darf nicht {@code null} sein
* @return der escaped String; nie {@code null}
*/
private static String escapeSqlLike(String input) {
return input
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
}
// -------------------------------------------------------------------------
// JDBC-Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Öffnet eine neue Datenbankverbindung zur konfigurierten SQLite-Datei.
* <p>
* Kann in Unterklassen überschrieben werden, um eine gemeinsam genutzte
* Transaktions-Verbindung bereitzustellen.
*
* @return eine neue Datenbankverbindung
* @throws SQLException wenn die Verbindung nicht hergestellt werden kann
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
}
/**
* Parst einen Instant aus einer String-Darstellung.
* <p>
* Unterstützt ISO-8601 (modern) und das Legacy-Format {@code yyyy-MM-dd HH:mm:ss} (UTC).
*
* @param stringValue die String-Darstellung; kann {@code null} sein
* @return das geparste Instant, oder {@code null} wenn die Eingabe leer oder nicht parsbar ist
*/
private Instant stringToInstant(String stringValue) {
if (stringValue == null || stringValue.isBlank()) {
return null;
}
try {
return Instant.parse(stringValue);
} catch (Exception e) {
try {
LocalDateTime dateTime = LocalDateTime.parse(stringValue,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return dateTime.atZone(ZoneId.of("UTC")).toInstant();
} catch (Exception fallback) {
logger.warn("Instant konnte nicht geparst werden '{}': {}", stringValue, fallback.getMessage());
return null;
}
}
}
}
@@ -7,8 +7,11 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;
import java.time.format.DateTimeFormatter;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -308,8 +311,8 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
new DocumentFingerprint(rs.getString("fingerprint")),
new RunId(rs.getString("run_id")),
rs.getInt("attempt_number"),
Instant.parse(rs.getString("started_at")),
Instant.parse(rs.getString("ended_at")),
stringToInstant(rs.getString("started_at")),
stringToInstant(rs.getString("ended_at")),
ProcessingStatus.valueOf(rs.getString("status")),
rs.getString("failure_class"),
rs.getString("failure_message"),
@@ -328,6 +331,35 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
);
}
/**
* Versucht, einen Instant aus einer String-Darstellung zu parsen.
* Unterstützt sowohl modernes ISO-8601-Format als auch Legacy-Format.
*
* @param stringValue die String-Darstellung des Datums, kann null sein
* @return das geparste Instant, oder null wenn stringValue null oder leer ist
*/
private Instant stringToInstant(String stringValue) {
if (stringValue == null || stringValue.isBlank()) {
return null;
}
// Versuch mit ISO-8601 Format (moderner Standard)
try {
return Instant.parse(stringValue);
} catch (Exception e) {
// Fallback auf älteres Format "yyyy-MM-dd HH:mm:ss" (als UTC)
try {
LocalDateTime dateTime = LocalDateTime.parse(stringValue,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return dateTime.atZone(ZoneId.of("UTC")).toInstant();
} catch (Exception fallbackException) {
logger.warn("Fehler beim Parsen der Instant-String '{}' in beiden Formaten (ISO-8601 und Legacy-Format)",
stringValue, fallbackException);
return null;
}
}
}
// -------------------------------------------------------------------------
// JDBC nullable helpers
// -------------------------------------------------------------------------
@@ -1,337 +1,577 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import javax.sql.DataSource;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.flywaydb.core.Flyway;
import org.sqlite.SQLiteConfig;
import org.sqlite.SQLiteDataSource;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort;
/**
* SQLite implementation of {@link PersistenceSchemaInitializationPort}.
* <p>
* Creates or verifies the two-level persistence schema in the configured SQLite
* database file, and performs a controlled schema evolution from an earlier schema
* version to the current one.
* Flyway-basierte Implementierung von {@link PersistenceSchemaInitializationPort}.
*
* <h2>Two-level schema</h2>
* <p>The schema consists of exactly two tables:
* <ol>
* <li><strong>{@code document_record}</strong> the document master record
* (Dokument-Stammsatz). One row per unique SHA-256 fingerprint.</li>
* <li><strong>{@code processing_attempt}</strong> the processing attempt history
* (Versuchshistorie). One row per historised processing attempt, referencing
* the master record via fingerprint.</li>
* </ol>
* <p>Erstellt oder verifiziert das Zwei-Ebenen-Persistenzschema in der konfigurierten
* SQLite-Datenbank und führt dabei eine differenzierte Startstrategie durch,
* die drei Fälle unterscheidet:
*
* <h2>Schema evolution</h2>
* <p>
* When upgrading from an earlier schema, this adapter uses idempotent
* {@code ALTER TABLE ... ADD COLUMN} statements for both tables. Columns that already
* exist are silently skipped, making the evolution safe to run on both fresh and existing
* databases. The current evolution adds:
* <ul>
* <li>AI-traceability columns to {@code processing_attempt}</li>
* <li>Target-copy columns ({@code last_target_path}, {@code last_target_file_name}) to
* {@code document_record}</li>
* <li>Target-copy column ({@code final_target_file_name}) to {@code processing_attempt}</li>
* <li>Provider-identifier column ({@code ai_provider}) to {@code processing_attempt};
* existing rows receive {@code NULL} as the default, which is the correct value for
* attempts recorded before provider tracking was introduced.</li>
* </ul>
* <h2>Fall 1 Leere Datenbank</h2>
* <p>Keine fachlichen Tabellen und keine Flyway-History-Tabelle vorhanden
* (bzw. Datei existiert noch nicht). Flyway führt {@code V1__initial_schema.sql}
* vollständig aus und legt das komplette Schema an.
*
* <h2>Legacy-state migration</h2>
* <p>
* Documents in an earlier positive intermediate state ({@code SUCCESS} recorded without
* a validated naming proposal) are idempotently migrated to {@code READY_FOR_AI} so that
* the AI naming pipeline processes them in the next run. Terminal negative states
* ({@code FAILED_RETRYABLE}, {@code FAILED_FINAL}, skip states) are left unchanged.
* <h2>Fall 2 Bestehende Datenbank ohne Flyway-History</h2>
* <p>Fachliche Tabellen sind vorhanden, aber die Flyway-History-Tabelle fehlt.
* Vor der Baseline-Eintralung wird eine vollständige Schema-Prüfung gegen das
* V1-Zielschema durchgeführt. Bei konformem Schema wird ein datiertes Backup der
* SQLite-Datei erstellt, und Flyway trägt nur eine Baseline ein (Skript wird
* <em>nicht</em> ausgeführt). Bei fehlendem Schema-Element bricht der Start mit
* einer klaren Fehlermeldung ab.
*
* <h2>Initialisation timing</h2>
* <p>This adapter must be invoked <em>once</em> at program startup, before the batch
* document processing loop begins.
* <h2>Fall 3 Folgestart mit Flyway-History</h2>
* <p>Flyway-History-Tabelle ist vorhanden. Flyway läuft idempotent und
* führt nur noch fehlende Migrationen aus.
*
* <h2>Architecture boundary</h2>
* <p>All JDBC connections, SQL DDL, and SQLite-specific behaviour are strictly confined
* to this class. No JDBC or SQLite types appear in the port interface or in any
* application/domain type.
* <h2>Fremdschlüssel</h2>
* <p>Foreign-Key-Durchsetzung wird über {@code SQLiteConfig.enforceForeignKeys(true)}
* auf DataSource-Ebene aktiviert, sodass jede neue Verbindung automatisch
* {@code PRAGMA foreign_keys = ON} erhält.
*
* <h2>Architekturgrenze</h2>
* <p>Alle JDBC-Verbindungen, SQL-DDL und SQLite-spezifisches Verhalten sind
* ausschließlich in dieser Klasse gekapselt. Im Port-Interface und in den
* Domain-/Application-Typen erscheinen keine JDBC- oder SQLite-Typen.
*/
public class SqliteSchemaInitializationAdapter implements PersistenceSchemaInitializationPort {
private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class);
// -------------------------------------------------------------------------
// DDL document_record table
// Erwartete Tabellen und Spalten gemäß V1-Zielschema
// -------------------------------------------------------------------------
/**
* DDL for the document master record table.
* <p>
* Columns: id (PK), fingerprint (unique), last_known_source_locator,
* last_known_source_file_name, overall_status, content_error_count,
* transient_error_count, last_failure_instant, last_success_instant,
* created_at, updated_at.
*/
private static final String DDL_CREATE_DOCUMENT_RECORD = """
CREATE TABLE IF NOT EXISTS document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
last_known_source_locator TEXT NOT NULL,
last_known_source_file_name TEXT NOT NULL,
overall_status TEXT NOT NULL,
content_error_count INTEGER NOT NULL DEFAULT 0,
transient_error_count INTEGER NOT NULL DEFAULT 0,
last_failure_instant TEXT,
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
)
""";
/** Alle erwarteten Spalten der Tabelle {@code document_record}. */
private static final Set<String> EXPECTED_COLUMNS_DOCUMENT_RECORD = Set.of(
"id", "fingerprint", "last_known_source_locator", "last_known_source_file_name",
"overall_status", "content_error_count", "transient_error_count",
"last_failure_instant", "last_success_instant", "created_at", "updated_at",
"last_target_path", "last_target_file_name"
);
/** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
"id", "fingerprint", "run_id", "attempt_number", "started_at", "ended_at",
"status", "failure_class", "failure_message", "retryable",
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
"validated_title", "final_target_file_name", "ai_provider"
);
/** Erwartete Indizes. */
private static final Set<String> EXPECTED_INDEXES = Set.of(
"idx_processing_attempt_fingerprint",
"idx_processing_attempt_run_id",
"idx_document_record_overall_status"
);
/** Name der Flyway-History-Tabelle. */
private static final String FLYWAY_HISTORY_TABLE = "flyway_schema_history";
// -------------------------------------------------------------------------
// DDL processing_attempt table (base schema, without AI traceability cols)
// Felder
// -------------------------------------------------------------------------
/**
* DDL for the base processing attempt history table.
* <p>
* Base columns (present in all schema versions): id, fingerprint, run_id,
* attempt_number, started_at, ended_at, status, failure_class, failure_message, retryable.
* <p>
* AI traceability columns are added separately via {@code ALTER TABLE} to support
* idempotent evolution from earlier schemas.
*/
private static final String DDL_CREATE_PROCESSING_ATTEMPT = """
CREATE TABLE IF NOT EXISTS processing_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
status TEXT NOT NULL,
failure_class TEXT,
failure_message TEXT,
retryable INTEGER NOT NULL DEFAULT 0,
CONSTRAINT fk_processing_attempt_fingerprint
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
CONSTRAINT uq_processing_attempt_fingerprint_number
UNIQUE (fingerprint, attempt_number)
)
""";
// -------------------------------------------------------------------------
// DDL indexes
// -------------------------------------------------------------------------
/** Index on {@code processing_attempt.fingerprint} for fast per-document lookups. */
private static final String DDL_IDX_ATTEMPT_FINGERPRINT =
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint "
+ "ON processing_attempt (fingerprint)";
/** Index on {@code processing_attempt.run_id} for fast per-run lookups. */
private static final String DDL_IDX_ATTEMPT_RUN_ID =
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_run_id "
+ "ON processing_attempt (run_id)";
/** Index on {@code document_record.overall_status} for fast status-based filtering. */
private static final String DDL_IDX_RECORD_STATUS =
"CREATE INDEX IF NOT EXISTS idx_document_record_overall_status "
+ "ON document_record (overall_status)";
// -------------------------------------------------------------------------
// DDL columns added to processing_attempt via schema evolution
// -------------------------------------------------------------------------
/**
* Columns to add idempotently to {@code processing_attempt}.
* Each entry is {@code [column_name, column_type]}.
* <p>
* {@code ai_provider} is nullable; existing rows receive {@code NULL}, which is the
* correct sentinel for attempts recorded before provider tracking was introduced.
*/
private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = {
{"model_name", "TEXT"},
{"prompt_identifier", "TEXT"},
{"processed_page_count", "INTEGER"},
{"sent_character_count", "INTEGER"},
{"ai_raw_response", "TEXT"},
{"ai_reasoning", "TEXT"},
{"resolved_date", "TEXT"},
{"date_source", "TEXT"},
{"validated_title", "TEXT"},
{"final_target_file_name", "TEXT"},
{"ai_provider", "TEXT"},
};
// -------------------------------------------------------------------------
// DDL columns added to document_record via schema evolution
// -------------------------------------------------------------------------
/**
* Columns to add idempotently to {@code document_record}.
* Each entry is {@code [column_name, column_type]}.
*/
private static final String[][] EVOLUTION_RECORD_COLUMNS = {
{"last_target_path", "TEXT"},
{"last_target_file_name", "TEXT"},
};
// -------------------------------------------------------------------------
// Legacy-state status migration
// -------------------------------------------------------------------------
/**
* Migrates earlier positive intermediate states in {@code document_record} that were
* recorded as {@code SUCCESS} without a validated naming proposal to {@code READY_FOR_AI},
* so the AI naming pipeline processes them in the next run.
* <p>
* Only rows with {@code overall_status = 'SUCCESS'} that have no corresponding
* {@code processing_attempt} with {@code status = 'PROPOSAL_READY'} are updated.
* This migration is idempotent.
*/
private static final String SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI = """
UPDATE document_record
SET overall_status = 'READY_FOR_AI',
updated_at = datetime('now')
WHERE overall_status = 'SUCCESS'
AND NOT EXISTS (
SELECT 1 FROM processing_attempt pa
WHERE pa.fingerprint = document_record.fingerprint
AND pa.status = 'PROPOSAL_READY'
)
""";
private final String jdbcUrl;
/**
* Constructs the adapter with the JDBC URL of the SQLite database file.
* Erstellt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
*
* @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
* @throws NullPointerException if {@code jdbcUrl} is null
* @throws IllegalArgumentException if {@code jdbcUrl} is blank
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
* @throws NullPointerException wenn {@code jdbcUrl} {@code null} ist
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
*/
public SqliteSchemaInitializationAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null");
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl must not be blank");
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
}
this.jdbcUrl = jdbcUrl;
}
/**
* Creates or verifies the persistence schema and performs schema evolution and
* status migration.
* <p>
* Execution order:
* <ol>
* <li>Enable foreign key enforcement.</li>
* <li>Create {@code document_record} table (if not exists).</li>
* <li>Create {@code processing_attempt} table (if not exists).</li>
* <li>Create all indexes (if not exist).</li>
* <li>Add AI-traceability and provider-identifier columns to {@code processing_attempt}
* (idempotent evolution).</li>
* <li>Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).</li>
* </ol>
* <p>
* All steps are safe to run on both fresh and existing databases.
* Erstellt oder verifiziert das Persistenzschema per Flyway.
*
* @throws DocumentPersistenceException if any DDL or migration step fails
* <p>Erkennt anhand des Datenbankzustands automatisch einen der drei Fälle
* (leere DB, bestehende DB ohne Flyway-History, Folgestart mit Flyway-History)
* und wählt die passende Flyway-Konfiguration.
*
* @throws DocumentPersistenceException wenn das Schema nicht erstellt oder verifiziert
* werden kann, oder wenn die Schema-Prüfung bei
* einer bestehenden Datenbank fehlschlägt
*/
@Override
public void initializeSchema() {
logger.info("Initialising SQLite persistence schema at: {}", jdbcUrl);
try (Connection connection = DriverManager.getConnection(jdbcUrl);
Statement statement = connection.createStatement()) {
logger.info("Schema-Initialisierung gestartet für: {}", jdbcUrl);
try {
DataSource dataSource = createDataSource();
DbState state = determineDbState(dataSource);
logger.info("Erkannter Datenbankzustand: {}", state);
// Enable foreign key enforcement (SQLite disables it by default)
statement.execute("PRAGMA foreign_keys = ON");
// Level 1: document master record
statement.execute(DDL_CREATE_DOCUMENT_RECORD);
logger.debug("Table 'document_record' created or already present.");
// Level 2: processing attempt history (base columns only)
statement.execute(DDL_CREATE_PROCESSING_ATTEMPT);
logger.debug("Table 'processing_attempt' created or already present.");
// Indexes for efficient per-document, per-run, and per-status access
statement.execute(DDL_IDX_ATTEMPT_FINGERPRINT);
statement.execute(DDL_IDX_ATTEMPT_RUN_ID);
statement.execute(DDL_IDX_RECORD_STATUS);
logger.debug("Indexes created or already present.");
// Schema evolution: add AI-traceability + target-copy columns (idempotent)
evolveTableColumns(connection, "processing_attempt", EVOLUTION_ATTEMPT_COLUMNS);
evolveTableColumns(connection, "document_record", EVOLUTION_RECORD_COLUMNS);
// Status migration: earlier positive intermediate state READY_FOR_AI
int migrated = statement.executeUpdate(SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI);
if (migrated > 0) {
logger.info("Status migration: {} document(s) migrated from legacy SUCCESS state to READY_FOR_AI.",
migrated);
} else {
logger.debug("Status migration: no documents required migration.");
switch (state) {
case EMPTY -> runFall1NewDb(dataSource);
case EXISTING_WITHOUT_FLYWAY -> runFall2BaselineExistingDb(dataSource);
case FLYWAY_MANAGED -> runFall3FollowUpStart(dataSource);
}
logger.info("SQLite schema initialisation and migration completed successfully.");
} catch (SQLException e) {
String message = "Failed to initialise SQLite persistence schema at '" + jdbcUrl + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
logger.info("Schema-Initialisierung erfolgreich abgeschlossen.");
} catch (DocumentPersistenceException e) {
throw e;
} catch (Exception e) {
String msg = "Schema-Initialisierung fehlgeschlagen für '" + jdbcUrl + "': " + e.getMessage();
logger.error(msg, e);
throw new DocumentPersistenceException(msg, e);
}
}
/**
* Idempotently adds the given columns to the specified table.
* <p>
* For each column that does not yet exist, an {@code ALTER TABLE ... ADD COLUMN}
* statement is executed. Columns that already exist are silently skipped.
* Gibt die JDBC-URL zurück, die dieser Adapter verwendet.
*
* @param connection an open JDBC connection to the database
* @param tableName the name of the table to evolve
* @param columns array of {@code [column_name, column_type]} pairs to add
* @throws SQLException if a column addition fails for a reason other than duplicate column
*/
private void evolveTableColumns(Connection connection, String tableName, String[][] columns)
throws SQLException {
java.util.Set<String> existingColumns = new java.util.HashSet<>();
try (ResultSet rs = connection.getMetaData().getColumns(null, null, tableName, null)) {
while (rs.next()) {
existingColumns.add(rs.getString("COLUMN_NAME").toLowerCase());
}
}
for (String[] col : columns) {
String columnName = col[0];
String columnType = col[1];
if (!existingColumns.contains(columnName.toLowerCase())) {
String alterSql = "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnType;
try (Statement stmt = connection.createStatement()) {
stmt.execute(alterSql);
}
logger.debug("Schema evolution: added column '{}' to '{}'.", columnName, tableName);
} else {
logger.debug("Schema evolution: column '{}' in '{}' already present, skipped.",
columnName, tableName);
}
}
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
*
* @return the JDBC URL; never null or blank
* @return die JDBC-URL; niemals {@code null} oder leer
*/
public String getJdbcUrl() {
return jdbcUrl;
}
// -------------------------------------------------------------------------
// Fallbehandlung
// -------------------------------------------------------------------------
/**
* Fall 1: Leere Datenbank Flyway führt V1__initial_schema.sql vollständig aus.
*
* @param dataSource die konfigurierte DataSource
*/
private void runFall1NewDb(DataSource dataSource) {
logger.info("Fall 1: Leere Datenbank Flyway legt vollständiges Schema an.");
Flyway flyway = buildFlyway(dataSource, false);
flyway.migrate();
logger.info("Fall 1: Schema vollständig erstellt.");
}
/**
* Fall 2: Bestehende Datenbank ohne Flyway-History.
*
* <p>Führt die vollständige Schema-Prüfcheckliste durch. Bei konformem Schema
* wird ein datiertes Backup angelegt und Flyway trägt nur eine Baseline ein.
* Bei fehlendem Schema-Element bricht der Start ab.
*
* @param dataSource die konfigurierte DataSource
* @throws DocumentPersistenceException wenn das Schema nicht konform ist oder das Backup schlägt fehl
*/
private void runFall2BaselineExistingDb(DataSource dataSource) {
logger.info("Fall 2: Bestehende Datenbank ohne Flyway-History Schema-Prüfung läuft.");
// Vollständige Schema-Prüfung vor Baseline
try (Connection conn = dataSource.getConnection()) {
verifyExistingSchemaMatches(conn);
} catch (SQLException e) {
String msg = "Datenbankverbindung für Schema-Prüfung fehlgeschlagen: " + e.getMessage();
logger.error(msg, e);
throw new DocumentPersistenceException(msg, e);
}
logger.info("Fall 2: Schema-Prüfung bestanden.");
// Backup der SQLite-Datei anlegen
createDatedBackup();
// Flyway-Baseline eintragen (V1 wird NICHT ausgeführt)
Flyway flyway = buildFlyway(dataSource, true);
flyway.migrate();
logger.info("Fall 2: Flyway-Baseline erfolgreich eingetragen.");
}
/**
* Fall 3: Folgestart Flyway läuft idempotent und führt nur fehlende Migrationen aus.
*
* @param dataSource die konfigurierte DataSource
*/
private void runFall3FollowUpStart(DataSource dataSource) {
logger.info("Fall 3: Folgestart mit Flyway-History idempotente Migration.");
Flyway flyway = buildFlyway(dataSource, false);
flyway.migrate();
logger.info("Fall 3: Migration abgeschlossen (idempotent).");
}
/**
* Erzeugt eine standardisiert konfigurierte {@link Flyway}-Instanz.
*
* <p>Alle drei Fälle nutzen dieselbe Grundkonfiguration:
* <ul>
* <li>Explizite Migrations-Location {@code classpath:db/migration} verhindert
* unerwünschtes Klasspfad-Scannen des gesamten JARs.</li>
* <li>Keine Umgebungsvariablen-Konfiguration verhindert unbeabsichtigte
* Übersteuerung durch Build-System-Variablen.</li>
* <li>Kein Verbindungs-Retry ({@code connectRetries=0}) Fehler schlagen
* sofort statt nach mehreren Sekunden Wartezeit fehl.</li>
* </ul>
*
* @param dataSource die zu verwendende DataSource
* @param baselineOnMigrate ob beim Migrate eine Baseline einzutragen ist (nur Fall 2)
* @return eine konfigurierte, betriebsbereite {@link Flyway}-Instanz
*/
private Flyway buildFlyway(DataSource dataSource, boolean baselineOnMigrate) {
var config = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.connectRetries(0)
.baselineOnMigrate(baselineOnMigrate);
if (baselineOnMigrate) {
config = config
.baselineVersion("1")
.baselineDescription("Bestehende Datenbank baselined");
}
return config.load();
}
// -------------------------------------------------------------------------
// Datenbankzustand erkennen
// -------------------------------------------------------------------------
/**
* Repräsentiert den erkannten Zustand der SQLite-Datenbank beim Start.
*/
enum DbState {
/** Keine fachlichen Tabellen und keine Flyway-History vorhanden. */
EMPTY,
/** Fachliche Tabellen vorhanden, aber keine Flyway-History-Tabelle. */
EXISTING_WITHOUT_FLYWAY,
/** Flyway-History-Tabelle vorhanden Datenbank wird bereits von Flyway verwaltet. */
FLYWAY_MANAGED
}
/**
* Ermittelt den aktuellen Zustand der Datenbank.
*
* <p>"Leer" bedeutet: keine Tabellen vorhanden nicht nur Dateigröße 0 Byte.
*
* @param dataSource die zu prüfende DataSource
* @return der erkannte {@link DbState}
* @throws DocumentPersistenceException bei Verbindungsfehlern
*/
private DbState determineDbState(DataSource dataSource) {
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData meta = conn.getMetaData();
Set<String> tables = readTableNames(meta);
if (tables.contains(FLYWAY_HISTORY_TABLE)) {
return DbState.FLYWAY_MANAGED;
}
// "Leer" = keine Tabellen vorhanden (unabhängig von Dateigröße)
boolean hasFachlicheTabellen = tables.contains("document_record")
|| tables.contains("processing_attempt");
if (hasFachlicheTabellen) {
return DbState.EXISTING_WITHOUT_FLYWAY;
}
return DbState.EMPTY;
} catch (SQLException e) {
String msg = "Datenbankzustand konnte nicht ermittelt werden: " + e.getMessage();
logger.error(msg, e);
throw new DocumentPersistenceException(msg, e);
}
}
// -------------------------------------------------------------------------
// Schema-Prüfcheckliste (Fall 2)
// -------------------------------------------------------------------------
/**
* Vollständige Schema-Prüfung gegen das V1-Zielschema.
*
* <p>Prüft alle erwarteten Tabellen, Spalten, Constraints und Indizes per
* {@link DatabaseMetaData}. Bei fehlendem Element wird der Start sofort mit
* einer aussagekräftigen Fehlermeldung abgebrochen kein stilles Heilen.
*
* @param conn offene JDBC-Verbindung zur Datenbank
* @throws DocumentPersistenceException wenn ein Schema-Element fehlt
* @throws SQLException bei technischen Datenbankfehlern
*/
private void verifyExistingSchemaMatches(Connection conn) throws SQLException {
DatabaseMetaData meta = conn.getMetaData();
List<String> fehler = new ArrayList<>();
// Tabellen prüfen
Set<String> tabellen = readTableNames(meta);
if (!tabellen.contains("document_record")) {
fehler.add("Tabelle 'document_record' fehlt");
}
if (!tabellen.contains("processing_attempt")) {
fehler.add("Tabelle 'processing_attempt' fehlt");
}
// Spalten prüfen nur wenn Tabellen vorhanden
if (tabellen.contains("document_record")) {
pruefeSpaltenvollstaendigkeit(meta, "document_record",
EXPECTED_COLUMNS_DOCUMENT_RECORD, fehler);
}
if (tabellen.contains("processing_attempt")) {
pruefeSpaltenvollstaendigkeit(meta, "processing_attempt",
EXPECTED_COLUMNS_PROCESSING_ATTEMPT, fehler);
}
// Indizes prüfen
if (tabellen.contains("document_record") && tabellen.contains("processing_attempt")) {
Set<String> vorhandeneIndizes = readIndexNames(meta);
for (String erwartetIndex : EXPECTED_INDEXES) {
if (!vorhandeneIndizes.contains(erwartetIndex)) {
fehler.add("Index '" + erwartetIndex + "' fehlt");
}
}
}
// Constraints prüfen (soweit per Metadata prüfbar)
if (tabellen.contains("document_record")) {
pruefeUniqueConstraintAufFingerprint(conn, fehler);
}
if (tabellen.contains("processing_attempt")) {
pruefeForeignKeyAufDocumentRecord(conn, fehler);
}
if (!fehler.isEmpty()) {
String fehlerliste = String.join("; ", fehler);
String msg = "Schema-Prüfung fehlgeschlagen folgende Elemente fehlen oder sind nicht konform: "
+ fehlerliste;
logger.error(msg);
throw new DocumentPersistenceException(msg);
}
}
/**
* Prüft, ob alle erwarteten Spalten in der angegebenen Tabelle vorhanden sind.
*
* @param meta Datenbankmetadaten
* @param tabellenname Name der zu prüfenden Tabelle
* @param erwarteteSpalten Menge der erwarteten Spaltennamen (Kleinschreibung)
* @param fehler Liste, in die fehlende Elemente eingetragen werden
* @throws SQLException bei technischen Datenbankfehlern
*/
private void pruefeSpaltenvollstaendigkeit(DatabaseMetaData meta, String tabellenname,
Set<String> erwarteteSpalten, List<String> fehler) throws SQLException {
Set<String> vorhandeneSpalten = new HashSet<>();
try (ResultSet rs = meta.getColumns(null, null, tabellenname, null)) {
while (rs.next()) {
vorhandeneSpalten.add(rs.getString("COLUMN_NAME").toLowerCase());
}
}
for (String erwartet : erwarteteSpalten) {
if (!vorhandeneSpalten.contains(erwartet)) {
fehler.add("Spalte '" + tabellenname + "." + erwartet + "' fehlt");
}
}
}
/**
* Prüft das UNIQUE-Constraint auf {@code document_record.fingerprint} anhand der
* Indexmetadaten.
*
* @param conn offene JDBC-Verbindung
* @param fehler Liste, in die fehlende Elemente eingetragen werden
* @throws SQLException bei technischen Datenbankfehlern
*/
private void pruefeUniqueConstraintAufFingerprint(Connection conn,
List<String> fehler) throws SQLException {
boolean uniqueGefunden = false;
try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, "document_record", true, false)) {
while (rs.next()) {
String spalte = rs.getString("COLUMN_NAME");
if ("fingerprint".equalsIgnoreCase(spalte)) {
uniqueGefunden = true;
break;
}
}
}
if (!uniqueGefunden) {
fehler.add("UNIQUE-Constraint auf 'document_record.fingerprint' fehlt");
}
}
/**
* Prüft den Foreign Key von {@code processing_attempt.fingerprint} auf
* {@code document_record.fingerprint} anhand der Importschlüssel-Metadaten.
*
* @param conn offene JDBC-Verbindung
* @param fehler Liste, in die fehlende Elemente eingetragen werden
* @throws SQLException bei technischen Datenbankfehlern
*/
private void pruefeForeignKeyAufDocumentRecord(Connection conn,
List<String> fehler) throws SQLException {
boolean fkGefunden = false;
try (ResultSet rs = conn.getMetaData().getImportedKeys(null, null, "processing_attempt")) {
while (rs.next()) {
String pkTabelle = rs.getString("PKTABLE_NAME");
String fkSpalte = rs.getString("FKCOLUMN_NAME");
if ("document_record".equalsIgnoreCase(pkTabelle)
&& "fingerprint".equalsIgnoreCase(fkSpalte)) {
fkGefunden = true;
break;
}
}
}
if (!fkGefunden) {
fehler.add("Foreign Key von 'processing_attempt.fingerprint' auf 'document_record.fingerprint' fehlt");
}
}
// -------------------------------------------------------------------------
// Backup-Erstellung (Fall 2)
// -------------------------------------------------------------------------
/**
* Erstellt eine datierte Kopie der SQLite-Datei als Backup.
*
* <p>Das Backup-Dateiname-Schema lautet: {@code <original>.<timestamp>.bak},
* z. B. {@code data.db.20260430T120000Z.bak}.
* Bei einer Kollision wird ein Zähler angehängt.
*
* @throws DocumentPersistenceException wenn das Backup nicht angelegt werden kann
*/
private void createDatedBackup() {
Path dbPath = extractDbPath();
if (dbPath == null) {
logger.warn("Kein lokaler Dateipfad aus JDBC-URL ableitbar Backup übersprungen: {}", jdbcUrl);
return;
}
if (!Files.exists(dbPath)) {
logger.debug("Datenbankdatei existiert noch nicht kein Backup nötig.");
return;
}
String zeitstempel = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
.format(java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC));
Path backup = dbPath.resolveSibling(dbPath.getFileName() + "." + zeitstempel + ".bak");
// Kollisionsauflösung
int zaehler = 1;
while (Files.exists(backup)) {
backup = dbPath.resolveSibling(dbPath.getFileName() + "." + zeitstempel + "." + zaehler + ".bak");
zaehler++;
}
try {
Files.copy(dbPath, backup, StandardCopyOption.COPY_ATTRIBUTES);
logger.info("Backup der Datenbankdatei erstellt: {}", backup);
} catch (IOException e) {
String msg = "Backup der Datenbankdatei konnte nicht erstellt werden: " + e.getMessage();
logger.error(msg, e);
throw new DocumentPersistenceException(msg, e);
}
}
/**
* Leitet den Dateisystempfad aus der JDBC-URL ab.
*
* <p>Erwartet URLs der Form {@code jdbc:sqlite:/pfad/zur/datei.db}.
*
* @return der abgeleitete {@link Path} oder {@code null}, wenn kein Pfad ableitbar ist
*/
private Path extractDbPath() {
// Erwartet: jdbc:sqlite:/pfad/zur/datei oder jdbc:sqlite:C:/pfad/datei
String prefix = "jdbc:sqlite:";
if (!jdbcUrl.startsWith(prefix)) {
return null;
}
String pfad = jdbcUrl.substring(prefix.length());
if (pfad.isBlank()) {
return null;
}
try {
return Paths.get(pfad);
} catch (Exception e) {
logger.warn("Pfad aus JDBC-URL konnte nicht geparst werden: {}", pfad);
return null;
}
}
// -------------------------------------------------------------------------
// DataSource-Erstellung
// -------------------------------------------------------------------------
/**
* Erstellt eine {@link SQLiteDataSource} mit aktivierten Fremdschlüsseln.
*
* <p>Die Aktivierung über {@link SQLiteConfig#enforceForeignKeys(boolean)} stellt
* sicher, dass jede neue Verbindung automatisch {@code PRAGMA foreign_keys = ON}
* erhält ein einmaliges Statement nach dem Verbindungsaufbau wäre nicht ausreichend.
*
* @return eine konfigurierte {@link DataSource}; niemals {@code null}
*/
private DataSource createDataSource() {
SQLiteConfig config = new SQLiteConfig();
config.enforceForeignKeys(true);
SQLiteDataSource ds = new SQLiteDataSource(config);
ds.setUrl(jdbcUrl);
return ds;
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Liest alle Tabellennamen aus den Datenbankmetadaten (Kleinschreibung).
*
* @param meta Datenbankmetadaten
* @return Menge aller Tabellennamen in Kleinschreibung
* @throws SQLException bei technischen Datenbankfehlern
*/
private static Set<String> readTableNames(DatabaseMetaData meta) throws SQLException {
Set<String> names = new HashSet<>();
try (ResultSet rs = meta.getTables(null, null, "%", new String[]{"TABLE"})) {
while (rs.next()) {
names.add(rs.getString("TABLE_NAME").toLowerCase());
}
}
return names;
}
/**
* Liest alle Indexnamen aus den Datenbankmetadaten für beide fachlichen Tabellen.
*
* @param meta Datenbankmetadaten
* @return Menge aller Indexnamen in Kleinschreibung
* @throws SQLException bei technischen Datenbankfehlern
*/
private static Set<String> readIndexNames(DatabaseMetaData meta) throws SQLException {
Set<String> names = new HashSet<>();
for (String tabelle : new String[]{"document_record", "processing_attempt"}) {
try (ResultSet rs = meta.getIndexInfo(null, null, tabelle, false, false)) {
while (rs.next()) {
String indexName = rs.getString("INDEX_NAME");
if (indexName != null) {
names.add(indexName.toLowerCase());
}
}
}
}
return names;
}
}
@@ -41,58 +41,51 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
public void executeInTransaction(Consumer<TransactionOperations> operations) {
Objects.requireNonNull(operations, "operations must not be null");
Connection connection = null;
try {
connection = DriverManager.getConnection(jdbcUrl);
try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
connection.setAutoCommit(false);
try {
TransactionOperationsImpl txOps = new TransactionOperationsImpl(connection);
operations.accept(txOps);
connection.commit();
logger.debug("Transaction committed successfully");
logger.debug("Transaktion erfolgreich abgeschlossen.");
} catch (DocumentPersistenceException e) {
// Re-throw document-level persistence errors as-is, but still rollback
if (connection != null) {
// Datenbankfehler auf Dokumentebene: Rollback, dann weiterpropagieren
try {
connection.rollback();
logger.debug("Transaction rolled back due to document error: {}", e.getMessage());
logger.debug("Transaktion zurückgerollt (Dokumentfehler): {}", e.getMessage());
} catch (SQLException rollbackEx) {
logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx);
}
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx);
}
throw e;
} catch (RuntimeException e) {
// Rollback on any RuntimeException and wrap in DocumentPersistenceException
if (connection != null) {
// Unerwarteter Laufzeitfehler: Rollback, dann als Persistenzfehler weitergeben
try {
connection.rollback();
logger.debug("Transaction rolled back due to error: {}", e.getMessage());
logger.debug("Transaktion zurückgerollt (Laufzeitfehler): {}", e.getMessage());
} catch (SQLException rollbackEx) {
logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx);
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx);
}
}
throw new DocumentPersistenceException("Transaction failed: " + e.getMessage(), e);
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
} catch (SQLException e) {
// Rollback for any SQL error
if (connection != null) {
// SQL-Fehler innerhalb der Transaktion: Rollback, dann als Persistenzfehler weitergeben
try {
connection.rollback();
logger.debug("Transaction rolled back due to error: {}", e.getMessage());
logger.debug("Transaktion zurückgerollt (SQL-Fehler): {}", e.getMessage());
} catch (SQLException rollbackEx) {
logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx);
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx);
}
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
}
throw new DocumentPersistenceException("Transaction failed: " + e.getMessage(), e);
} finally {
if (connection != null) {
try {
connection.close();
} catch (DocumentPersistenceException e) {
throw e;
} catch (SQLException e) {
logger.warn("Failed to close connection: {}", e.getMessage(), e);
}
}
// Verbindungsaufbau oder setAutoCommit(false) fehlgeschlagen
throw new DocumentPersistenceException(
"Datenbankverbindung konnte nicht hergestellt werden: " + e.getMessage(), e);
}
}
@@ -178,7 +171,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
*/
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// Delete attempts first (FK constraint: processing_attempt document_record)
// Zuerst Versuche löschen (FK-Constraint: processing_attempt document_record)
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
@Override
@@ -188,7 +181,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
};
attemptRepo.deleteAllByFingerprint(fingerprint);
// Then delete the master record
// Dann den Stammsatz löschen
SqliteDocumentRecordRepositoryAdapter recordRepo =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
@Override
@@ -198,5 +191,45 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
};
recordRepo.deleteByFingerprint(fingerprint);
}
/**
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
* ohne die Versuchshistorie zu löschen.
* <p>
* Die Felder {@code overall_status}, {@code content_error_count},
* {@code transient_error_count} und {@code last_failure_instant} werden innerhalb
* der laufenden Transaktion per direktem SQL-UPDATE aktualisiert.
* Alle anderen Felder sowie alle {@code processing_attempt}-Einträge bleiben unverändert.
* <p>
* Ist kein Stammsatz für den Fingerprint vorhanden, kehrt die Methode stillschweigend zurück.
*
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
String sql = """
UPDATE document_record SET
overall_status = 'READY_FOR_AI',
content_error_count = 0,
transient_error_count = 0,
last_failure_instant = NULL
WHERE fingerprint = ?
""";
try (java.sql.PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, fingerprint.sha256Hex());
stmt.executeUpdate();
logger.debug("Status-Reset (feldgenau) für Fingerprint: {}", fingerprint.sha256Hex());
} catch (java.sql.SQLException e) {
String message = "Status-Reset fehlgeschlagen für Fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
}
}
@@ -1,35 +1,43 @@
/**
* SQLite persistence adapter for the two-level persistence model.
* SQLite-Persistenz-Adapter für das Zwei-Ebenen-Persistenzmodell.
*
* <h2>Purpose</h2>
* <p>This package contains the technical SQLite infrastructure for the persistence
* layer. It is the only place in the entire application where JDBC connections, SQL DDL,
* and SQLite-specific types are used. No JDBC or SQLite types leak into the
* {@code application} or {@code domain} modules.
* <h2>Zweck</h2>
* <p>Dieses Paket enthält die technische SQLite-Infrastruktur der Persistenzschicht.
* Es ist die einzige Stelle in der gesamten Anwendung, an der JDBC-Verbindungen,
* SQL-DDL und SQLite-spezifische Typen verwendet werden. Keine JDBC- oder
* SQLite-Typen verlassen dieses Paket in Richtung der {@code application}-
* oder {@code domain}-Module.
*
* <h2>Two-level persistence model</h2>
* <p>Persistence is structured in exactly two levels:
* <h2>Zwei-Ebenen-Persistenzmodell</h2>
* <p>Die Persistenz ist in genau zwei Ebenen strukturiert:
* <ol>
* <li><strong>Document master record</strong> ({@code document_record} table)
* one row per unique SHA-256 fingerprint; carries the current overall status,
* failure counters, and the most recently known source location.</li>
* <li><strong>Processing attempt history</strong> ({@code processing_attempt} table)
* one row per historised processing attempt; references the master record via
* fingerprint; attempt numbers are monotonically increasing per fingerprint.</li>
* <li><strong>Dokument-Stammsatz</strong> ({@code document_record}-Tabelle)
* eine Zeile pro eindeutigem SHA-256-Fingerprint; trägt den aktuellen
* Gesamtstatus, Fehlerzähler und den zuletzt bekannten Quellort.</li>
* <li><strong>Versuchshistorie</strong> ({@code processing_attempt}-Tabelle)
* eine Zeile pro historisiertem Verarbeitungsversuch; referenziert den
* Stammsatz über den Fingerprint; Versuchsnummern sind pro Fingerprint
* monoton steigend.</li>
* </ol>
*
* <h2>Schema initialisation timing</h2>
* <p>The {@link de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter}
* implements the
* <h2>Schema-Initialisierung mit Flyway</h2>
* <p>Der {@link de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter}
* implementiert den
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort}
* and must be called <em>once</em> at program startup, before the batch document
* processing loop begins. There is no lazy or hidden initialisation during document
* processing.
* und muss <em>einmal</em> beim Programmstart aufgerufen werden, bevor die
* Verarbeitungsschleife beginnt. Die Initialisierung unterscheidet drei Fälle:
* leere Datenbank, bestehende Datenbank ohne Flyway-History (Baseline-Eintragung
* nach vollständiger Schema-Prüfung) und Folgestart mit Flyway-History (idempotent).
*
* <h2>Architecture boundary</h2>
* <p>All JDBC connections, SQL statements, and SQLite-specific behaviour are strictly
* confined to this package. The application layer interacts exclusively through the
* port interfaces defined in
* <h2>Fremdschlüssel</h2>
* <p>Foreign-Key-Durchsetzung wird über {@code SQLiteConfig.enforceForeignKeys(true)}
* auf DataSource-Ebene aktiviert, sodass jede neue Verbindung automatisch
* {@code PRAGMA foreign_keys = ON} erhält.
*
* <h2>Architekturgrenze</h2>
* <p>Alle JDBC-Verbindungen, SQL-Anweisungen und SQLite-spezifisches Verhalten sind
* ausschließlich in diesem Paket gekapselt. Die Application-Schicht interagiert
* ausschließlich über die Port-Interfaces in
* {@code de.gecheckt.pdf.umbenenner.application.port.out}.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
@@ -0,0 +1,58 @@
-- Vollständiges Basisschema: Dokument-Stammsatz und Versuchshistorie.
-- Dieses Skript wird für neue Datenbanken ausgeführt (Fall 1).
-- Für bestehende Datenbanken mit konformem Schema wird nur eine Flyway-Baseline
-- eingetragen; das Skript wird in diesem Fall NICHT ausgeführt (Fall 2).
CREATE TABLE document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
last_known_source_locator TEXT NOT NULL,
last_known_source_file_name TEXT NOT NULL,
overall_status TEXT NOT NULL,
content_error_count INTEGER NOT NULL DEFAULT 0,
transient_error_count INTEGER NOT NULL DEFAULT 0,
last_failure_instant TEXT,
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_target_path TEXT,
last_target_file_name TEXT,
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
);
CREATE TABLE processing_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
status TEXT NOT NULL,
failure_class TEXT,
failure_message TEXT,
retryable INTEGER NOT NULL DEFAULT 0,
model_name TEXT,
prompt_identifier TEXT,
processed_page_count INTEGER,
sent_character_count INTEGER,
ai_raw_response TEXT,
ai_reasoning TEXT,
resolved_date TEXT,
date_source TEXT,
validated_title TEXT,
final_target_file_name TEXT,
ai_provider TEXT,
CONSTRAINT fk_processing_attempt_fingerprint
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
CONSTRAINT uq_processing_attempt_fingerprint_number
UNIQUE (fingerprint, attempt_number)
);
CREATE INDEX idx_processing_attempt_fingerprint
ON processing_attempt (fingerprint);
CREATE INDEX idx_processing_attempt_run_id
ON processing_attempt (run_id);
CREATE INDEX idx_document_record_overall_status
ON document_record (overall_status);
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
/**
* Unit tests for {@link FilesystemPromptPortAdapter}.
@@ -199,4 +200,135 @@ class FilesystemPromptPortAdapterTest {
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
}
// -------------------------------------------------------------------------
// savePrompt tests
// -------------------------------------------------------------------------
@Test
void savePrompt_shouldReturnSaved_whenTargetDirExistsAndWriteSucceeds() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_save.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Mein Prompt-Inhalt";
// When
PromptSaveResult result = adapter.savePrompt(content);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
PromptSaveResult.Saved saved = (PromptSaveResult.Saved) result;
assertThat(saved.absolutePath()).contains("prompt_save.txt");
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
}
@Test
void savePrompt_shouldPreserveUtf8Content_includingUmlauts() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_umlaut.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Ärger mit Überschriften und Schluß";
// When
PromptSaveResult result = adapter.savePrompt(content);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
}
@Test
void savePrompt_shouldPreserveLineEndings_withoutNormalization() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_lineendings.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Zeile 1\r\nZeile 2\nZeile 3\r\n";
// When
PromptSaveResult result = adapter.savePrompt(content);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
byte[] raw = Files.readAllBytes(promptFile);
assertThat(new String(raw, StandardCharsets.UTF_8)).isEqualTo(content);
}
@Test
void savePrompt_shouldOverwriteExistingFile_atomically() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_overwrite.txt");
Files.writeString(promptFile, "Alter Inhalt", StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
String newContent = "Neuer Inhalt";
// When
PromptSaveResult result = adapter.savePrompt(newContent);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(newContent);
}
@Test
void savePrompt_shouldReturnTargetDirectoryMissing_whenDirectoryDoesNotExist() {
// Given
Path nonExistentDir = tempDir.resolve("missing-subdir");
Path promptFile = nonExistentDir.resolve("prompt.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptSaveResult result = adapter.savePrompt("Inhalt");
// Then
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
PromptSaveResult.TargetDirectoryMissing missing = (PromptSaveResult.TargetDirectoryMissing) result;
assertThat(missing.message()).contains("missing-subdir");
}
@Test
void savePrompt_shouldThrowNullPointerException_whenContentIsNull() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_null.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
// When & Then
assertThatThrownBy(() -> adapter.savePrompt(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("content must not be null");
}
@Test
void savePrompt_shouldLeaveDirClean_whenTargetDirectoryIsMissing() {
// Given Verzeichnis existiert nicht; keine Temp-Datei soll zurückbleiben
Path nonExistentDir = tempDir.resolve("ghost-dir");
Path promptFile = nonExistentDir.resolve("prompt.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptSaveResult result = adapter.savePrompt("Inhalt");
// Then
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
// Verzeichnis wurde nicht angelegt (da Directory-Check fehlschlug)
assertThat(nonExistentDir).doesNotExist();
}
@Test
void savePrompt_roundTrip_loadAfterSaveReturnsSameContent() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_roundtrip.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Runde-Trip-Inhalt\nMit mehreren Zeilen.";
// When
PromptSaveResult saveResult = adapter.savePrompt(content);
PromptLoadingResult loadResult = adapter.loadPrompt();
// Then
assertThat(saveResult).isInstanceOf(PromptSaveResult.Saved.class);
assertThat(loadResult).isInstanceOf(PromptLoadingSuccess.class);
PromptLoadingSuccess success = (PromptLoadingSuccess) loadResult;
// loadPrompt trims the content; trim the expected too
assertThat(success.promptContent()).isEqualTo(content.trim());
}
}
@@ -24,11 +24,11 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/**
* Tests for the additive {@code ai_provider} column in {@code processing_attempt}.
* <p>
* Covers schema migration (idempotency, nullable default for existing rows),
* write/read round-trips for both supported provider identifiers, and
* backward compatibility with databases created before provider tracking was introduced.
* Tests für {@code ai_provider} in {@code processing_attempt}.
*
* <p>Prüft Schreib-/Lese-Roundtrips für beide Provider-Identifikatoren,
* Idempotenz der Initialisierung sowie das Verhalten bei Schemata,
* die nicht dem Zielschema entsprechen (harter Abbruch per Fall-2-Strategie).
*/
class SqliteAttemptProviderPersistenceTest {
@@ -64,25 +64,24 @@ class SqliteAttemptProviderPersistenceTest {
}
/**
* A database that already has the {@code processing_attempt} table without
* {@code ai_provider} (simulating an existing installation before this column was added)
* must receive the column via the idempotent schema evolution.
* Eine bestehende Datenbank ohne {@code ai_provider}-Spalte in {@code processing_attempt}
* entspricht nicht dem vollständigen Zielschema. Die Initialisierung muss mit einem
* klaren Fehler abbrechen, da kein stilles Heilen stattfindet.
*/
@Test
void addsProviderColumnOnExistingDbWithoutColumn() throws SQLException {
// Bootstrap schema without the ai_provider column (simulate legacy DB)
void existingDbOhneAiProviderSpalte_brichtAb() throws SQLException {
// Schema ohne ai_provider anlegen
createLegacySchema();
assertThat(columnExists("processing_attempt", "ai_provider"))
.as("ai_provider must not be present before evolution")
.as("ai_provider darf im Legacy-Schema noch nicht vorhanden sein")
.isFalse();
// Running initializeSchema must add the column
schemaAdapter.initializeSchema();
assertThat(columnExists("processing_attempt", "ai_provider"))
.as("ai_provider column must be added by schema evolution")
.isTrue();
// Initialisierung muss mit Fehler abbrechen (nicht konformes Schema)
org.junit.jupiter.api.Assertions.assertThrows(
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
() -> schemaAdapter.initializeSchema(),
"Erwarte Fehler bei nicht konformem Schema (fehlende ai_provider-Spalte)");
}
/**
@@ -101,25 +100,28 @@ class SqliteAttemptProviderPersistenceTest {
}
/**
* Rows that existed before the {@code ai_provider} column was added must have
* {@code NULL} as the column value, not a non-null default.
* Neue Versuche die ohne Provider-Information gespeichert werden (z. B. über
* {@code ProcessingAttempt.withoutAiFields}), müssen {@code null} als
* {@code ai_provider} zurückliefern.
*/
@Test
void existingRowsKeepNullProvider() throws SQLException {
// Create legacy schema and insert a row without ai_provider
createLegacySchema();
DocumentFingerprint fp = fingerprint("aa");
insertLegacyDocumentRecord(fp);
insertLegacyAttemptRow(fp, "READY_FOR_AI");
// Now evolve the schema
void neuerVersuchOhneProvider_haeltNullProviderNachSchreibenUndLesen() {
schemaAdapter.initializeSchema();
DocumentFingerprint fp = fingerprint("aa");
insertDocumentRecord(fp);
// Read the existing row ai_provider must be NULL
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
assertThat(attempts).hasSize(1);
assertThat(attempts.get(0).aiProvider())
.as("Existing rows must have NULL ai_provider after schema evolution")
java.time.Instant now = java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS);
ProcessingAttempt attemptOhneProvider = ProcessingAttempt.withoutAiFields(
fp, new RunId("run-null"), 1,
now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE,
"Err", "msg", true);
repository.save(attemptOhneProvider);
List<ProcessingAttempt> gelesen = repository.findAllByFingerprint(fp);
assertThat(gelesen).hasSize(1);
assertThat(gelesen.get(0).aiProvider())
.as("Versuche ohne Provider müssen null zurückgeben")
.isNull();
}
@@ -213,29 +215,24 @@ class SqliteAttemptProviderPersistenceTest {
}
/**
* Reading a database that was created without the {@code ai_provider} column
* (a pre-extension database) must succeed; the new field must be empty/null
* for historical attempts.
* Eine Datenbank mit nicht konformem Schema (fehlende Spalten, fehlende Indizes)
* wird von der Initialisierung mit einem klaren Fehler abgebrochen.
* Es findet kein stilles Heilen statt.
*/
@Test
void legacyDataReadingDoesNotFail() throws SQLException {
// Set up legacy schema with a row that has no ai_provider column
void nichtKonformesSchema_brichtMitAussagekraeftigemFehlerAb() throws SQLException {
// Legacy-Schema anlegen (fehlt: ai_provider, last_target_path, last_target_file_name,
// Indizes fehlen ebenfalls)
createLegacySchema();
DocumentFingerprint fp = fingerprint("ee");
insertLegacyDocumentRecord(fp);
insertLegacyAttemptRow(fp, "FAILED_RETRYABLE");
// Evolve schema now ai_provider column exists but legacy rows have NULL
schemaAdapter.initializeSchema();
// Reading must not throw and must return null for ai_provider
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
assertThat(attempts).hasSize(1);
assertThat(attempts.get(0).aiProvider())
.as("Legacy attempt (from before provider tracking) must have null aiProvider")
.isNull();
// Other fields must still be readable
assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
// Initialisierung muss abbrechen
org.junit.jupiter.api.Assertions.assertThrows(
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
() -> schemaAdapter.initializeSchema(),
"Erwarte Fehler bei nicht konformem Bestands-Schema");
}
/**
@@ -3,6 +3,7 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
@@ -14,38 +15,34 @@ import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.sqlite.SQLiteConfig;
import org.sqlite.SQLiteDataSource;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
/**
* Tests for {@link SqliteSchemaInitializationAdapter}.
* <p>
* Verifies that the two-level schema is created correctly, that schema evolution
* (idempotent addition of AI traceability columns) works, that the idempotent
* status migration of earlier positive intermediate states to {@code READY_FOR_AI}
* is correct, and that invalid configuration is rejected.
* Tests für {@link SqliteSchemaInitializationAdapter}.
*
* <p>Prüft die differenzierte 3-Fall-Strategie (leere DB, bestehende DB ohne
* Flyway-History, Folgestart), die vollständige Schema-Prüfcheckliste für Fall 2,
* die Foreign-Key-Aktivierung via DataSource sowie den Konstruktor.
*/
class SqliteSchemaInitializationAdapterTest {
@TempDir
Path tempDir;
// -------------------------------------------------------------------------
// Construction
// Konstruktor
// -------------------------------------------------------------------------
@Test
void constructor_rejectsNullJdbcUrl() {
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("jdbcUrl");
.isInstanceOf(NullPointerException.class);
}
@Test
void constructor_rejectsBlankJdbcUrl() {
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(" "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("jdbcUrl");
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@@ -56,213 +53,278 @@ class SqliteSchemaInitializationAdapterTest {
}
// -------------------------------------------------------------------------
// Schema creation tables present
// Fall 1: Leere Datenbank vollständiges Schema anlegen
// -------------------------------------------------------------------------
@Test
void initializeSchema_createsBothTables(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "schema_test.db");
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
void fall1_leereDb_laegtVollstaendigesSchemaAn(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "fall1.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
adapter.initializeSchema();
Set<String> tables = readTableNames(jdbcUrl);
assertThat(tables).contains("document_record", "processing_attempt");
Set<String> tabellen = readTableNames(jdbcUrl);
assertThat(tabellen).contains("document_record", "processing_attempt");
}
@Test
void initializeSchema_documentRecordHasAllMandatoryColumns(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "columns_test.db");
void fall1_leereDb_documentRecordHatAlleErwartetenSpalten(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "fall1_columns_dr.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
Set<String> columns = readColumnNames(jdbcUrl, "document_record");
assertThat(columns).containsExactlyInAnyOrder(
"id",
"fingerprint",
"last_known_source_locator",
"last_known_source_file_name",
"overall_status",
"content_error_count",
"transient_error_count",
"last_failure_instant",
"last_success_instant",
"created_at",
"updated_at",
"last_target_path",
"last_target_file_name"
Set<String> spalten = readColumnNames(jdbcUrl, "document_record");
assertThat(spalten).containsExactlyInAnyOrder(
"id", "fingerprint", "last_known_source_locator", "last_known_source_file_name",
"overall_status", "content_error_count", "transient_error_count",
"last_failure_instant", "last_success_instant", "created_at", "updated_at",
"last_target_path", "last_target_file_name"
);
}
@Test
void initializeSchema_processingAttemptHasAllMandatoryColumns(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "attempt_columns_test.db");
void fall1_leereDb_processingAttemptHatAlleErwartetenSpalten(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "fall1_columns_pa.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
assertThat(columns).containsExactlyInAnyOrder(
"id",
"fingerprint",
"run_id",
"attempt_number",
"started_at",
"ended_at",
"status",
"failure_class",
"failure_message",
"retryable",
"model_name",
"prompt_identifier",
"processed_page_count",
"sent_character_count",
"ai_raw_response",
"ai_reasoning",
"resolved_date",
"date_source",
"validated_title",
"final_target_file_name",
"ai_provider"
Set<String> spalten = readColumnNames(jdbcUrl, "processing_attempt");
assertThat(spalten).containsExactlyInAnyOrder(
"id", "fingerprint", "run_id", "attempt_number", "started_at", "ended_at",
"status", "failure_class", "failure_message", "retryable",
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
"validated_title", "final_target_file_name", "ai_provider"
);
}
// -------------------------------------------------------------------------
// Idempotency
// -------------------------------------------------------------------------
@Test
void initializeSchema_isIdempotent_calledTwice(@TempDir Path dir) {
String jdbcUrl = jdbcUrl(dir, "idempotent_test.db");
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
void fall1_leereDb_indizesVorhanden(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "fall1_indexes.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
// Must not throw on second call
adapter.initializeSchema();
adapter.initializeSchema();
Set<String> indizes = readIndexNames(jdbcUrl);
assertThat(indizes).contains(
"idx_processing_attempt_fingerprint",
"idx_processing_attempt_run_id",
"idx_document_record_overall_status"
);
}
/**
* "Leer" bedeutet: keine Tabellen vorhanden NICHT nur Dateigröße 0 Byte.
* Eine leere SQLite-Datei (0 Byte) muss als leere DB erkannt werden.
*/
@Test
void fall1_erkenntLeereDbAuchBeiDateiOhneInhalt(@TempDir Path dir) throws Exception {
// Leere Datei anlegen (0 Byte)
Path dbPath = dir.resolve("empty.db");
Files.createFile(dbPath);
assertThat(dbPath).exists();
String jdbcUrl = jdbcUrl(dir, "empty.db");
// Muss als Fall 1 behandelt werden und erfolgreich durchlaufen
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
Set<String> tabellen = readTableNames(jdbcUrl);
assertThat(tabellen).contains("document_record", "processing_attempt");
}
// -------------------------------------------------------------------------
// Unique constraint: fingerprint in document_record
// Fall 2: Bestehende DB ohne Flyway-History Baseline eintragen
// -------------------------------------------------------------------------
@Test
void documentRecord_fingerprintUniqueConstraintIsEnforced(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "unique_test.db");
void fall2_bestehendeDbOhneHistory_traegtBaseline_einUndLaeuftErfolgreich(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "fall2.db");
// Vollständiges konformes Schema anlegen (wie eine bestehende Produktions-DB)
erstelleKonformesSchema(jdbcUrl);
// Adapter muss als Fall 2 erkennen und Baseline eintragen
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String insertSql = """
INSERT INTO document_record
(fingerprint, last_known_source_locator, last_known_source_file_name,
overall_status, created_at, updated_at)
VALUES (?, 'locator', 'file.pdf', 'SUCCESS', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
""";
// Flyway-History-Tabelle muss jetzt vorhanden sein
Set<String> tabellen = readTableNames(jdbcUrl);
assertThat(tabellen).contains("flyway_schema_history");
// Fachliche Daten müssen erhalten bleiben
assertThat(tabellen).contains("document_record", "processing_attempt");
}
@Test
void fall2_bestehendeDbOhneHistory_erstelltDatiertesBackup(@TempDir Path dir)
throws Exception {
Path dbPath = dir.resolve("fall2_backup.db");
String jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath().toString().replace('\\', '/');
erstelleKonformesSchema(jdbcUrl);
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
// Backup-Datei muss vorhanden sein
long backupAnzahl = Files.list(dir)
.filter(p -> p.getFileName().toString().startsWith("fall2_backup.db.")
&& p.getFileName().toString().endsWith(".bak"))
.count();
assertThat(backupAnzahl).isEqualTo(1);
}
@Test
void fall2_bestehendeDbMitFehlendemElement_brichtMitFehlerAb(@TempDir Path dir) {
String jdbcUrl = jdbcUrl(dir, "fall2_broken.db");
// Schema ohne Spalte ai_provider anlegen (nicht konform)
erstelleSchemaOhneAiProvider(jdbcUrl);
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema())
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("ai_provider");
}
@Test
void fall2_bestehendeDbOhneProcessingAttemptTabelle_brichtAb(@TempDir Path dir) {
String jdbcUrl = jdbcUrl(dir, "fall2_no_attempt.db");
// Nur document_record anlegen, processing_attempt fehlt
erstelleNurDocumentRecord(jdbcUrl);
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema())
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("processing_attempt");
}
// -------------------------------------------------------------------------
// Fall 3: Folgestart mit Flyway-History idempotent
// -------------------------------------------------------------------------
@Test
void fall3_folgestart_laeuftIdempotentOhneException(@TempDir Path dir) {
String jdbcUrl = jdbcUrl(dir, "fall3.db");
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
// Erster Aufruf (Fall 1)
adapter.initializeSchema();
// Zweiter Aufruf (Fall 3) darf nicht werfen
adapter.initializeSchema();
// Dritter Aufruf (Fall 3) ebenfalls idempotent
adapter.initializeSchema();
}
@Test
void fall3_folgestart_fachlicheDatenBleiben(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "fall3_data.db");
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
adapter.initializeSchema();
// Testdatensatz einfügen
String fp = "a".repeat(64);
insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS");
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
try (var ps = conn.prepareStatement(insertSql)) {
ps.setString(1, fp);
// Folgestart
adapter.initializeSchema();
// Daten müssen erhalten bleiben
assertThat(leseStatus(jdbcUrl, fp)).isEqualTo("SUCCESS");
}
// -------------------------------------------------------------------------
// PRAGMA foreign_keys Foreign-Key-Aktivierung via DataSource
// -------------------------------------------------------------------------
@Test
void foreignKeys_sindNachSchemaInitAktiv(@TempDir Path dir) throws Exception {
String jdbcUrl = jdbcUrl(dir, "fk_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
// Neue Verbindung über SQLiteConfig aufbauen (wie der Adapter es tut)
org.sqlite.SQLiteConfig config = new org.sqlite.SQLiteConfig();
config.enforceForeignKeys(true);
org.sqlite.SQLiteDataSource ds = new org.sqlite.SQLiteDataSource(config);
ds.setUrl(jdbcUrl);
try (Connection conn = ds.getConnection();
var stmt = conn.createStatement()) {
// PRAGMA foreign_keys muss 1 zurückliefern
ResultSet rs = stmt.executeQuery("PRAGMA foreign_keys");
assertThat(rs.next()).isTrue();
assertThat(rs.getInt(1)).isEqualTo(1);
}
}
@Test
void foreignKeys_verletzungWirdDurchgesetzt(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "fk_enforced.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
// Versuch, einen processing_attempt ohne passendem document_record einzufügen
org.sqlite.SQLiteConfig config = new org.sqlite.SQLiteConfig();
config.enforceForeignKeys(true);
org.sqlite.SQLiteDataSource ds = new org.sqlite.SQLiteDataSource(config);
ds.setUrl(jdbcUrl);
try (Connection conn = ds.getConnection()) {
assertThatThrownBy(() -> {
try (var ps = conn.prepareStatement("""
INSERT INTO processing_attempt
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
VALUES ('nichtvorhanden', 'run-1', 1, '2026-01-01T00:00:00Z',
'2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1)
""")) {
ps.executeUpdate();
}
// Second insert with same fingerprint must fail
try (var ps = conn.prepareStatement(insertSql)) {
ps.setString(1, fp);
org.junit.jupiter.api.Assertions.assertThrows(
SQLException.class, ps::executeUpdate,
"Expected UNIQUE constraint violation on document_record.fingerprint");
}
}).isInstanceOf(SQLException.class);
}
}
// -------------------------------------------------------------------------
// Unique constraint: (fingerprint, attempt_number) in processing_attempt
// Eindeutigkeits-Constraints
// -------------------------------------------------------------------------
@Test
void processingAttempt_fingerprintAttemptNumberUniqueConstraintIsEnforced(@TempDir Path dir)
void documentRecord_fingerprintUniqueConstraintWirdDurchgesetzt(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "attempt_unique_test.db");
String jdbcUrl = jdbcUrl(dir, "unique_dr.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fp = "b".repeat(64);
insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS");
// Insert master record first (FK)
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
try (var ps = conn.prepareStatement("""
INSERT INTO document_record
(fingerprint, last_known_source_locator, last_known_source_file_name,
overall_status, created_at, updated_at)
VALUES (?, 'loc', 'f.pdf', 'FAILED_RETRYABLE', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
""")) {
ps.setString(1, fp);
ps.executeUpdate();
// Zweiter Insert mit gleichem Fingerprint muss fehlschlagen
assertThatThrownBy(() -> insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS"))
.isInstanceOf(SQLException.class);
}
String attemptSql = """
INSERT INTO processing_attempt
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
VALUES (?, 'run-1', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1)
""";
try (var ps = conn.prepareStatement(attemptSql)) {
ps.setString(1, fp);
ps.executeUpdate();
}
// Duplicate (fingerprint, attempt_number) must fail
try (var ps = conn.prepareStatement(attemptSql)) {
ps.setString(1, fp);
org.junit.jupiter.api.Assertions.assertThrows(
SQLException.class, ps::executeUpdate,
"Expected UNIQUE constraint violation on (fingerprint, attempt_number)");
}
}
}
// -------------------------------------------------------------------------
// Skip attempts are storable
// -------------------------------------------------------------------------
@Test
void processingAttempt_skipStatusIsStorable(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "skip_test.db");
void processingAttempt_fingerprintUndAttemptNumberUniqueConstraintWirdDurchgesetzt(
@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "unique_pa.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fp = "c".repeat(64);
insertiereDocumentRecord(jdbcUrl, fp, "FAILED_RETRYABLE");
insertiereProcessingAttempt(jdbcUrl, fp, 1);
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
// Insert master record
try (var ps = conn.prepareStatement("""
INSERT INTO document_record
(fingerprint, last_known_source_locator, last_known_source_file_name,
overall_status, created_at, updated_at)
VALUES (?, 'loc', 'f.pdf', 'SUCCESS', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
""")) {
ps.setString(1, fp);
ps.executeUpdate();
}
// Insert a SKIPPED_ALREADY_PROCESSED attempt (null failure fields, retryable=0)
try (var ps = conn.prepareStatement("""
INSERT INTO processing_attempt
(fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable)
VALUES (?, 'run-2', 2, '2026-01-02T00:00:00Z', '2026-01-02T00:00:01Z',
'SKIPPED_ALREADY_PROCESSED', NULL, NULL, 0)
""")) {
ps.setString(1, fp);
int rows = ps.executeUpdate();
assertThat(rows).isEqualTo(1);
}
}
// Zweiter Insert mit gleicher (fingerprint, attempt_number) muss fehlschlagen
assertThatThrownBy(() -> insertiereProcessingAttempt(jdbcUrl, fp, 1))
.isInstanceOf(SQLException.class);
}
// -------------------------------------------------------------------------
// Schema evolution AI traceability columns
// Fehlerfall: ungültige URL
// -------------------------------------------------------------------------
@Test
void initializeSchema_addsAiTraceabilityColumnsToExistingSchema(@TempDir Path dir)
throws SQLException {
// Simulate a pre-evolution schema: create the base tables without AI columns
String jdbcUrl = jdbcUrl(dir, "evolution_test.db");
void initializeSchema_wirftDocumentPersistenceException_beiUngueltigerUrl() {
SqliteSchemaInitializationAdapter adapter =
new SqliteSchemaInitializationAdapter("keine-jdbc-url");
assertThatThrownBy(adapter::initializeSchema)
.isInstanceOf(DocumentPersistenceException.class);
}
// -------------------------------------------------------------------------
// Hilfsmethoden Schema-Erstellung für Tests
// -------------------------------------------------------------------------
/**
* Erstellt ein vollständig konformes Schema (entspricht V1-Zielschema) ohne Flyway-History.
*/
private static void erstelleKonformesSchema(String jdbcUrl) {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var stmt = conn.createStatement()) {
stmt.execute("PRAGMA foreign_keys = ON");
stmt.execute("""
CREATE TABLE IF NOT EXISTS document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -276,6 +338,8 @@ class SqliteSchemaInitializationAdapterTest {
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_target_path TEXT,
last_target_file_name TEXT,
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
)
""");
@@ -290,112 +354,118 @@ class SqliteSchemaInitializationAdapterTest {
status TEXT NOT NULL,
failure_class TEXT,
failure_message TEXT,
retryable INTEGER NOT NULL DEFAULT 0
retryable INTEGER NOT NULL DEFAULT 0,
model_name TEXT,
prompt_identifier TEXT,
processed_page_count INTEGER,
sent_character_count INTEGER,
ai_raw_response TEXT,
ai_reasoning TEXT,
resolved_date TEXT,
date_source TEXT,
validated_title TEXT,
final_target_file_name TEXT,
ai_provider TEXT,
CONSTRAINT fk_processing_attempt_fingerprint
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
CONSTRAINT uq_processing_attempt_fingerprint_number
UNIQUE (fingerprint, attempt_number)
)
""");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint ON processing_attempt (fingerprint)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_processing_attempt_run_id ON processing_attempt (run_id)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_document_record_overall_status ON document_record (overall_status)");
} catch (SQLException e) {
throw new RuntimeException("Testvorbereitungsfehler: Schema konnte nicht erstellt werden", e);
}
}
// Running initializeSchema on the existing base schema must succeed (evolution)
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
/**
* Erstellt ein Schema ohne die Spalte {@code ai_provider} in {@code processing_attempt}.
*/
private static void erstelleSchemaOhneAiProvider(String jdbcUrl) {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
last_known_source_locator TEXT NOT NULL,
last_known_source_file_name TEXT NOT NULL,
overall_status TEXT NOT NULL,
content_error_count INTEGER NOT NULL DEFAULT 0,
transient_error_count INTEGER NOT NULL DEFAULT 0,
last_failure_instant TEXT,
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_target_path TEXT,
last_target_file_name TEXT,
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
)
""");
// processing_attempt OHNE ai_provider
stmt.execute("""
CREATE TABLE processing_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
status TEXT NOT NULL,
failure_class TEXT,
failure_message TEXT,
retryable INTEGER NOT NULL DEFAULT 0,
model_name TEXT,
prompt_identifier TEXT,
processed_page_count INTEGER,
sent_character_count INTEGER,
ai_raw_response TEXT,
ai_reasoning TEXT,
resolved_date TEXT,
date_source TEXT,
validated_title TEXT,
final_target_file_name TEXT
)
""");
} catch (SQLException e) {
throw new RuntimeException("Testvorbereitungsfehler", e);
}
}
Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
assertThat(columns).contains(
"model_name", "prompt_identifier", "processed_page_count",
"sent_character_count", "ai_raw_response", "ai_reasoning",
"resolved_date", "date_source", "validated_title");
/**
* Erstellt nur die Tabelle {@code document_record} (ohne {@code processing_attempt}).
*/
private static void erstelleNurDocumentRecord(String jdbcUrl) {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
last_known_source_locator TEXT NOT NULL,
last_known_source_file_name TEXT NOT NULL,
overall_status TEXT NOT NULL,
content_error_count INTEGER NOT NULL DEFAULT 0,
transient_error_count INTEGER NOT NULL DEFAULT 0,
last_failure_instant TEXT,
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""");
} catch (SQLException e) {
throw new RuntimeException("Testvorbereitungsfehler", e);
}
}
// -------------------------------------------------------------------------
// Status migration earlier positive intermediate state READY_FOR_AI
// -------------------------------------------------------------------------
@Test
void initializeSchema_migrates_legacySuccessWithoutProposal_toReadyForAi(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
// Insert a document with SUCCESS status and no PROPOSAL_READY attempt
String fp = "d".repeat(64);
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
// Run schema initialisation again (migration step runs every time)
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String status = readOverallStatus(jdbcUrl, fp);
assertThat(status).isEqualTo("READY_FOR_AI");
}
@Test
void initializeSchema_migration_isIdempotent(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_idempotent_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fp = "e".repeat(64);
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
// Run migration twice must not corrupt data or throw
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String status = readOverallStatus(jdbcUrl, fp);
assertThat(status).isEqualTo("READY_FOR_AI");
}
@Test
void initializeSchema_doesNotMigrate_successWithProposalReadyAttempt(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_proposal_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fp = "f".repeat(64);
// SUCCESS document that already has a PROPOSAL_READY attempt must NOT be migrated
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
insertAttemptWithStatus(jdbcUrl, fp, "PROPOSAL_READY");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String status = readOverallStatus(jdbcUrl, fp);
assertThat(status).isEqualTo("SUCCESS");
}
@Test
void initializeSchema_doesNotMigrate_terminalFailureStates(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_failure_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fpRetryable = "1".repeat(64);
String fpFinal = "2".repeat(64);
insertDocumentRecordWithStatus(jdbcUrl, fpRetryable, "FAILED_RETRYABLE");
insertDocumentRecordWithStatus(jdbcUrl, fpFinal, "FAILED_FINAL");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
assertThat(readOverallStatus(jdbcUrl, fpRetryable)).isEqualTo("FAILED_RETRYABLE");
assertThat(readOverallStatus(jdbcUrl, fpFinal)).isEqualTo("FAILED_FINAL");
}
// -------------------------------------------------------------------------
// Error handling
// -------------------------------------------------------------------------
@Test
void initializeSchema_throwsDocumentPersistenceException_onInvalidUrl() {
// SQLite is lenient with paths; use a truly invalid JDBC URL format
SqliteSchemaInitializationAdapter badAdapter =
new SqliteSchemaInitializationAdapter("not-a-jdbc-url-at-all");
assertThatThrownBy(badAdapter::initializeSchema)
.isInstanceOf(DocumentPersistenceException.class);
}
// -------------------------------------------------------------------------
// Helpers
// Hilfsmethoden JDBC
// -------------------------------------------------------------------------
private static String jdbcUrl(Path dir, String filename) {
return "jdbc:sqlite:" + dir.resolve(filename).toAbsolutePath();
return "jdbc:sqlite:" + dir.resolve(filename).toAbsolutePath().toString().replace('\\', '/');
}
private static Set<String> readTableNames(String jdbcUrl) throws SQLException {
@@ -411,7 +481,8 @@ class SqliteSchemaInitializationAdapterTest {
return tables;
}
private static Set<String> readColumnNames(String jdbcUrl, String tableName) throws SQLException {
private static Set<String> readColumnNames(String jdbcUrl, String tableName)
throws SQLException {
Set<String> columns = new HashSet<>();
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
DatabaseMetaData meta = conn.getMetaData();
@@ -424,7 +495,25 @@ class SqliteSchemaInitializationAdapterTest {
return columns;
}
private static void insertDocumentRecordWithStatus(String jdbcUrl, String fingerprint,
private static Set<String> readIndexNames(String jdbcUrl) throws SQLException {
Set<String> indexes = new HashSet<>();
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
DatabaseMetaData meta = conn.getMetaData();
for (String table : new String[]{"document_record", "processing_attempt"}) {
try (ResultSet rs = meta.getIndexInfo(null, null, table, false, false)) {
while (rs.next()) {
String name = rs.getString("INDEX_NAME");
if (name != null) {
indexes.add(name.toLowerCase());
}
}
}
}
}
return indexes;
}
private static void insertiereDocumentRecord(String jdbcUrl, String fingerprint,
String status) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var ps = conn.prepareStatement("""
@@ -439,21 +528,22 @@ class SqliteSchemaInitializationAdapterTest {
}
}
private static void insertAttemptWithStatus(String jdbcUrl, String fingerprint,
String status) throws SQLException {
private static void insertiereProcessingAttempt(String jdbcUrl, String fingerprint,
int attemptNumber) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var ps = conn.prepareStatement("""
INSERT INTO processing_attempt
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
VALUES (?, 'run-1', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', ?, 0)
VALUES (?, 'run-1', ?, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z',
'FAILED_RETRYABLE', 1)
""")) {
ps.setString(1, fingerprint);
ps.setString(2, status);
ps.setInt(2, attemptNumber);
ps.executeUpdate();
}
}
private static String readOverallStatus(String jdbcUrl, String fingerprint) throws SQLException {
private static String leseStatus(String jdbcUrl, String fingerprint) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var ps = conn.prepareStatement(
"SELECT overall_status FROM document_record WHERE fingerprint = ?")) {
@@ -462,7 +552,7 @@ class SqliteSchemaInitializationAdapterTest {
if (rs.next()) {
return rs.getString("overall_status");
}
throw new IllegalStateException("No document record found for fingerprint: " + fingerprint);
throw new IllegalStateException("Kein Eintrag für Fingerprint: " + fingerprint);
}
}
}
+7 -1
View File
@@ -6,7 +6,7 @@
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<version>${revision}</version>
</parent>
<artifactId>pdf-umbenenner-application</artifactId>
<packaging>jar</packaging>
@@ -19,6 +19,12 @@
<version>${project.version}</version>
</dependency>
<!-- Logging API (nur API, keine Implementierung gebunden durch Bootstrap) -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
</dependency>
<!-- JSON parsing for AI response parsing -->
<dependency>
<groupId>org.json</groupId>
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
@@ -1,56 +1,76 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound port for loading external prompt templates.
* Outbound-Port zum Laden und Speichern des externen Prompt-Templates.
* <p>
* This interface abstracts the loading of prompt content from external sources
* (files, resources, databases, etc.), allowing the Application layer to remain
* independent of how or where prompts are stored.
* Dieses Interface abstrahiert den Zugriff auf die Prompt-Datei und erlaubt der
* Application-Schicht, unabhängig vom konkreten Speichermedium zu bleiben.
* <p>
* <strong>Design principles:</strong>
* <strong>Designprinzipien:</strong>
* <ul>
* <li>Prompt is not embedded in code; it is loaded from an external source</li>
* <li>Each prompt receives a stable identifier for traceability across batch runs</li>
* <li>Results are returned as structured types ({@link PromptLoadingResult}),
* never as exceptions</li>
* <li>Der Prompt wird nicht im Code fest verdrahtet, sondern aus einer externen Quelle geladen.</li>
* <li>Jeder Prompt erhält einen stabilen Identifikator für die lückenlose Nachvollziehbarkeit.</li>
* <li>Ergebnisse werden als strukturierte Typen zurückgegeben, niemals als Exceptions.</li>
* <li>Der Pfad zur Prompt-Datei ist Implementierungsdetail des Adapters er erscheint nicht
* in der Port-Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen).</li>
* </ul>
* <p>
* <strong>Adapter responsibilities:</strong>
* <strong>Adapter-Verantwortung:</strong>
* <ul>
* <li>Locate and read the prompt file/resource from the configured source</li>
* <li>Derive a stable prompt identifier (e.g., filename, semantic version, content hash)</li>
* <li>Validate that the loaded content is not empty or otherwise invalid</li>
* <li>Return either success or a classified failure</li>
* <li>Encapsulate all file I/O, resource loading, and configuration details</li>
* <li>Prompt-Datei lokalisieren und lesen.</li>
* <li>Stabilen Identifikator ableiten (z. B. Dateiname).</li>
* <li>Leere oder technisch unbrauchbare Prompts ablehnen.</li>
* <li>Beim Speichern: atomares Schreiben via temporäre Datei und {@code ATOMIC_MOVE}.</li>
* <li>Alle Datei-I/O-, Ressourcen- und Konfigurationsdetails kapseln.</li>
* </ul>
* <p>
* <strong>Non-goals of this port:</strong>
* <strong>Nicht-Ziele dieses Ports:</strong>
* <ul>
* <li>Prompt parsing or templating logic</li>
* <li>Combining prompt with document text (Application layer handles this)</li>
* <li>Template variable substitution</li>
* <li>Validation of prompt content against domain rules</li>
* <li>Prompt-Parsing oder Template-Verarbeitung</li>
* <li>Kombination von Prompt und Dokumenttext (Application-Schicht)</li>
* <li>Validierung des Prompt-Inhalts gegen Domänenregeln</li>
* </ul>
*/
public interface PromptPort {
/**
* Loads the configured external prompt template.
* Lädt das konfigurierte externe Prompt-Template.
* <p>
* This method is called once per batch run to obtain the current prompt.
* The prompt content and its stable identifier are returned together.
* Diese Methode wird einmal pro Verarbeitungslauf aufgerufen, um den aktuellen Prompt zu laden.
* Inhalt und stabiler Identifikator werden gemeinsam zurückgegeben.
* <p>
* If loading fails for any reason (file not found, I/O error, content validation),
* a {@link PromptLoadingFailure} is returned rather than throwing an exception.
*
* @return a {@link PromptLoadingResult} encoding either:
* <ul>
* <li>Success: prompt content and identifier loaded successfully</li>
* <li>Failure: prompt could not be loaded or is invalid</li>
* </ul>
* Bei einem technischen Fehler (Datei nicht gefunden, I/O-Fehler, leerer Inhalt) wird
* {@link PromptLoadingFailure} zurückgegeben keine Exception wird geworfen.
*
* @return {@link PromptLoadingResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @see PromptLoadingSuccess
* @see PromptLoadingFailure
*/
PromptLoadingResult loadPrompt();
/**
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
* <p>
* Der Zielpfad wird intern aus der Konfiguration des Adapters ermittelt und ist
* <em>nicht</em> Teil dieser Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen
* im Port-Vertrag).
* <p>
* Die Implementierung schreibt zunächst in eine temporäre Datei <em>im selben Verzeichnis</em>
* wie die Zieldatei und verschiebt diese danach atomar via {@code ATOMIC_MOVE}.
* Bei einem Fehler beim atomaren Verschieben wird <strong>kein stiller Fallback</strong>
* auf ein nicht-atomares Schreiben durchgeführt; stattdessen wird
* {@link PromptSaveResult.AtomicMoveFailed} zurückgegeben.
* <p>
* Zeichenkodierung: UTF-8. Zeilenenden werden unverändert übernommen.
*
* @param content der zu speichernde Prompt-Inhalt; darf leer sein (Entscheidung liegt
* beim Aufrufer, ob ein leerer Prompt erwünscht ist)
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @throws NullPointerException wenn {@code content} null ist
* @see PromptSaveResult.Saved
* @see PromptSaveResult.WriteFailed
* @see PromptSaveResult.TargetDirectoryMissing
* @see PromptSaveResult.AtomicMoveFailed
*/
PromptSaveResult savePrompt(String content);
}
@@ -0,0 +1,96 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Versiegeltes Ergebnis-Interface für das Speichern einer Prompt-Datei via
* {@link PromptPort#savePrompt(String)}.
* <p>
* Mögliche Ergebnisse:
* <ul>
* <li>{@link Saved} das Speichern war erfolgreich.</li>
* <li>{@link WriteFailed} ein technischer Fehler beim Schreiben ist aufgetreten.</li>
* <li>{@link TargetDirectoryMissing} der konfigurierte Zielordner existiert nicht.</li>
* <li>{@link AtomicMoveFailed} das atomare Verschieben der temporären Datei ist
* fehlgeschlagen; kein stiller Fallback.</li>
* </ul>
*/
public sealed interface PromptSaveResult
permits PromptSaveResult.Saved,
PromptSaveResult.WriteFailed,
PromptSaveResult.TargetDirectoryMissing,
PromptSaveResult.AtomicMoveFailed {
/**
* Die Prompt-Datei wurde erfolgreich gespeichert.
*
* @param absolutePath absoluter Pfad der gespeicherten Datei; nie {@code null}
*/
record Saved(String absolutePath) implements PromptSaveResult {
/**
* Erstellt ein Saved-Ergebnis.
*
* @param absolutePath absoluter Pfad der gespeicherten Datei; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code absolutePath} null ist
*/
public Saved {
java.util.Objects.requireNonNull(absolutePath, "absolutePath must not be null");
}
}
/**
* Das Schreiben der temporären Datei ist fehlgeschlagen.
*
* @param message Fehlerbeschreibung; nie {@code null}
* @param cause auslösende Ausnahme; kann {@code null} sein
*/
record WriteFailed(String message, Throwable cause) implements PromptSaveResult {
/**
* Erstellt ein WriteFailed-Ergebnis.
*
* @param message Fehlerbeschreibung; darf nicht {@code null} sein
* @param cause auslösende Ausnahme; kann {@code null} sein
* @throws NullPointerException wenn {@code message} null ist
*/
public WriteFailed {
java.util.Objects.requireNonNull(message, "message must not be null");
}
}
/**
* Der konfigurierte Zielordner existiert nicht.
*
* @param message Beschreibung des fehlenden Ordners; nie {@code null}
*/
record TargetDirectoryMissing(String message) implements PromptSaveResult {
/**
* Erstellt ein TargetDirectoryMissing-Ergebnis.
*
* @param message Beschreibung des fehlenden Ordners; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code message} null ist
*/
public TargetDirectoryMissing {
java.util.Objects.requireNonNull(message, "message must not be null");
}
}
/**
* Das atomare Verschieben der temporären Datei zur Zieldatei ist fehlgeschlagen.
* Es wird kein stiller Fallback auf nicht-atomares Schreiben durchgeführt.
*
* @param message Fehlerbeschreibung; nie {@code null}
*/
record AtomicMoveFailed(String message) implements PromptSaveResult {
/**
* Erstellt ein AtomicMoveFailed-Ergebnis.
*
* @param message Fehlerbeschreibung; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code message} null ist
*/
public AtomicMoveFailed {
java.util.Objects.requireNonNull(message, "message must not be null");
}
}
}
@@ -60,5 +60,28 @@ public interface UnitOfWorkPort {
* @throws DocumentPersistenceException if the delete fails due to a technical error
*/
void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
/**
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
* ohne die Versuchshistorie zu löschen.
* <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>
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
* {@code last_target_path}, {@code last_target_file_name} sowie alle
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
* <p>
* Nach diesem Aufruf gilt das Dokument beim nächsten Lauf als verarbeitbar.
*
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
void resetDocumentStatusForRetry(DocumentFingerprint fingerprint);
}
}
@@ -0,0 +1,50 @@
package de.gecheckt.pdf.umbenenner.application.port.out.history;
import java.time.Instant;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
/**
* Einzelzeile der Dokumentenliste im Historien-Tab.
* <p>
* Enthält alle Felder, die für die linke Tabelle des Historien-Tabs benötigt werden.
* Die Felder stammen aus {@code document_record} und einem {@code COUNT}-Ausdruck über
* {@code processing_attempt}.
*
* @param fingerprint Inhalts-basierter Dokumentbezeichner; nie {@code null}
* @param overallStatus aktueller Gesamtstatus des Dokuments; nie {@code null}
* @param sourceFileName zuletzt bekannter Quelldateiname; nie {@code null}
* @param targetFileName zuletzt bekannter Zieldateiname; {@code null} falls noch kein
* erfolgreicher Lauf stattgefunden hat
* @param sourcePath zuletzt bekannter Quellpfad (opaker Locator-Wert); nie {@code null}
* @param updatedAt Zeitpunkt der letzten Aktualisierung des Stammsatzes; nie {@code null}
* @param attemptCount Anzahl historisierter Verarbeitungsversuche; immer &gt;= 0
*/
public record DocumentHistoryRow(
DocumentFingerprint fingerprint,
ProcessingStatus overallStatus,
String sourceFileName,
String targetFileName,
String sourcePath,
Instant updatedAt,
long attemptCount) {
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws NullPointerException wenn ein Pflichtfeld {@code null} ist
* @throws IllegalArgumentException wenn {@code attemptCount} negativ ist
*/
public DocumentHistoryRow {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
Objects.requireNonNull(overallStatus, "overallStatus darf nicht null sein");
Objects.requireNonNull(sourceFileName, "sourceFileName darf nicht null sein");
Objects.requireNonNull(sourcePath, "sourcePath darf nicht null sein");
Objects.requireNonNull(updatedAt, "updatedAt darf nicht null sein");
if (attemptCount < 0) {
throw new IllegalArgumentException("attemptCount darf nicht negativ sein, war: " + attemptCount);
}
}
}
@@ -0,0 +1,65 @@
package de.gecheckt.pdf.umbenenner.application.port.out.history;
/**
* Abfrageparameter für den Historien-Tab.
* <p>
* Kapselt Freitextsuche, optionalen Status-Filter und das Limit der zurückzugebenden
* Zeilen. Das Limit ist bewusst auf 501 gesetzt, damit die aufrufende Schicht erkennen
* kann, ob mehr als 500 Treffer vorhanden sind.
*
* @param searchText optionaler Suchbegriff (Teilstring, case-insensitiv); {@code null}
* oder leer bedeutet keine Texteinschränkung
* @param statusFilter optionaler Status-Filter als Enum-Name; {@code null} bedeutet alle
* Status werden angezeigt
* @param limit maximale Anzahl zurückzugebender Zeilen; muss &gt;= 1 sein
*/
public record HistoryQuery(
String searchText,
String statusFilter,
int limit) {
/**
* Standard-Limit: 501 Zeilen abfragen, um bei Bedarf mehr vorhanden" erkennen zu können.
*/
public static final int DEFAULT_LIMIT = 501;
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws IllegalArgumentException wenn {@code limit} kleiner als 1 ist
*/
public HistoryQuery {
if (limit < 1) {
throw new IllegalArgumentException("limit muss mindestens 1 sein, war: " + limit);
}
}
/**
* Erzeugt eine Abfrage ohne Filter mit Standard-Limit.
*
* @return neue Abfrage ohne Einschränkungen
*/
public static HistoryQuery unfiltered() {
return new HistoryQuery(null, null, DEFAULT_LIMIT);
}
/**
* Erzeugt eine Abfrage mit Freitextsuche und Standard-Limit.
*
* @param searchText Suchbegriff; {@code null} oder leer bedeutet kein Filter
* @return neue Abfrage mit Textfilter
*/
public static HistoryQuery withSearchText(String searchText) {
return new HistoryQuery(searchText, null, DEFAULT_LIMIT);
}
/**
* Erzeugt eine Abfrage mit Status-Filter und Standard-Limit.
*
* @param statusFilter Enum-Name des gewünschten Status; {@code null} bedeutet kein Filter
* @return neue Abfrage mit Status-Filter
*/
public static HistoryQuery withStatus(String statusFilter) {
return new HistoryQuery(null, statusFilter, DEFAULT_LIMIT);
}
}
@@ -0,0 +1,61 @@
package de.gecheckt.pdf.umbenenner.application.port.out.history;
import java.util.List;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Outbound-Port für lesende Historien-Abfragen aus dem Historien-Tab.
* <p>
* Kapselt alle Datenbanklese-Operationen, die der Historien-Tab benötigt.
* Die Implementierung liegt ausschließlich in {@code pdf-umbenenner-adapter-out}.
* Die Application-Schicht kennt nur diesen Port-Vertrag keine JDBC-Typen.
*
* <h2>Architektur</h2>
* <p>
* Dieser Port ist bewusst von {@link de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository}
* und {@link de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository}
* getrennt, damit die bestehenden Repositories nicht mit GUI-spezifischen Methoden
* aufgebläht werden.
*/
public interface HistoryQueryPort {
/**
* Lädt eine gefilterte und sortierte Übersicht aller Dokumenteneinträge.
* <p>
* Sortierung: {@code updated_at DESC, fingerprint ASC} (stabiler Tie-Breaker).
* Das in {@link HistoryQuery#limit()} angegebene Limit wird direkt als SQL-{@code LIMIT}
* angewendet. Wenn das Limit 501 beträgt und 501 Zeilen zurückgegeben werden, gibt es
* mehr als 500 Treffer.
*
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
* technischen Datenbankfehlern
*/
List<DocumentHistoryRow> loadOverview(HistoryQuery query);
/**
* Lädt den vollständigen Dokumenten-Stammsatz für den angegebenen Fingerprint.
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
* technischen Datenbankfehlern
*/
Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint);
/**
* Lädt alle historisierten Verarbeitungsversuche für den angegebenen Fingerprint,
* aufsteigend sortiert nach {@code attempt_number}.
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return unveränderliche Liste der Versuche; nie {@code null}; kann leer sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
* technischen Datenbankfehlern
*/
List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint);
}
@@ -0,0 +1,12 @@
/**
* Outbound-Ports und DTOs für lesende Historien-Abfragen des Historien-Tabs.
* <p>
* Enthält den {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort}
* sowie die zugehörigen Datentypen
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery} und
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow}.
* Diese Typen sind bewusst vom bestehenden {@code port.out}-Paket getrennt,
* damit die allgemeinen Repository-Schnittstellen nicht mit GUI-spezifischen Methoden
* belastet werden.
*/
package de.gecheckt.pdf.umbenenner.application.port.out.history;
@@ -6,7 +6,6 @@ import java.util.Objects;
import java.util.Set;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator.AiValidationResult;
import de.gecheckt.pdf.umbenenner.domain.model.AiErrorClassification;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
@@ -6,6 +6,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailureReason;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
@@ -26,10 +27,14 @@ import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
* <li><strong>Naming proposal ready:</strong> Status becomes
* {@link ProcessingStatus#PROPOSAL_READY}, counters unchanged,
* {@code retryable=false}.</li>
* <li><strong>Pre-check content error (first occurrence):</strong>
* <li><strong>Pre-check content error {@link PreCheckFailureReason#NO_USABLE_TEXT}:</strong>
* Status becomes {@link ProcessingStatus#FAILED_FINAL} immediately,
* content error counter incremented by 1, {@code retryable=false}.
* Image-only PDFs without OCR text will not yield usable text on retry.</li>
* <li><strong>Pre-check content error (other reason, first occurrence):</strong>
* Status becomes {@link ProcessingStatus#FAILED_RETRYABLE},
* content error counter incremented by 1, {@code retryable=true}.</li>
* <li><strong>Pre-check content error (second or later occurrence):</strong>
* <li><strong>Pre-check content error (other reason, second or later occurrence):</strong>
* Status becomes {@link ProcessingStatus#FAILED_FINAL},
* content error counter incremented by 1, {@code retryable=false}.</li>
* <li><strong>AI functional failure (first occurrence):</strong>
@@ -112,11 +117,16 @@ final class ProcessingOutcomeTransition {
);
}
case PreCheckFailed ignored2 -> {
// Deterministic content error from pre-check: apply the 1-retry rule
case PreCheckFailed preCheckFailed -> {
FailureCounters updatedCounters = existingCounters.withIncrementedContentErrorCount();
boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0;
if (preCheckFailed.failureReason() == PreCheckFailureReason.NO_USABLE_TEXT) {
// Image-only PDFs without OCR text will not change on retry.
yield new ProcessingOutcome(ProcessingStatus.FAILED_FINAL, updatedCounters, false);
}
// Other deterministic content errors: apply the 1-retry rule
boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0;
if (isFirstOccurrence) {
yield new ProcessingOutcome(ProcessingStatus.FAILED_RETRYABLE, updatedCounters, true);
} else {
@@ -0,0 +1,65 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Use-Case-Implementierung für das vollständige Löschen eines Dokumenteintrags
* aus dem Historien-Tab.
* <p>
* Löscht innerhalb einer Transaktion in der korrekten Reihenfolge, um den
* Foreign-Key-Constraint zwischen {@code processing_attempt.fingerprint} und
* {@code document_record.fingerprint} zu erfüllen (kein {@code ON DELETE CASCADE}):
* <ol>
* <li>Alle {@code processing_attempt}-Einträge zum Fingerprint</li>
* <li>Den {@code document_record}-Stammsatz zum Fingerprint</li>
* </ol>
* Die Operation ist idempotent: wenn kein Datensatz für den Fingerprint existiert,
* kehrt die Methode stillschweigend zurück.
* <p>
* <strong>Hinweis:</strong> Diese Aktion ist destruktiv und nicht rückgängig zu machen.
* Die GUI muss vor dem Aufruf einen Bestätigungsdialog anzeigen.
*/
public class DefaultDeleteDocumentHistoryUseCase {
private static final Logger logger = LogManager.getLogger(DefaultDeleteDocumentHistoryUseCase.class);
private final UnitOfWorkPort unitOfWorkPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
*
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
*/
public DefaultDeleteDocumentHistoryUseCase(UnitOfWorkPort unitOfWorkPort) {
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
}
/**
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
* <p>
* Die Löschung erfolgt in einer einzigen Transaktion. Versuche werden vor dem
* Stammsatz gelöscht, damit der Foreign-Key-Constraint eingehalten wird.
*
* @param fingerprint der Dokumentbezeichner, dessen Daten vollständig gelöscht werden sollen;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
* @throws NullPointerException wenn {@code fingerprint} null ist
*/
public void deleteHistory(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
// Nutzung der bestehenden Transaktion mit korrekter Löschreihenfolge:
// zuerst Versuche, dann Stammsatz (FK-Constraint)
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentByFingerprint(fingerprint));
logger.info("Dokumenteintrag vollständig gelöscht für Fingerprint: {}", fingerprint.sha256Hex());
}
}
@@ -0,0 +1,74 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Use-Case-Implementierung für das Laden der Detailansicht eines Dokuments im Historien-Tab.
* <p>
* Kombiniert den Dokument-Stammsatz und alle historisierten Verarbeitungsversuche
* für einen bestimmten Fingerprint in einem einzigen Ergebnisobjekt.
* <p>
* Wird kein Stammsatz gefunden (z. B. weil das Dokument zwischenzeitlich gelöscht wurde),
* liefert {@link #loadDetails(DocumentFingerprint)} ein leeres {@link Optional}.
*/
public class DefaultHistoryDetailsUseCase {
private final HistoryQueryPort historyQueryPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
*
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code historyQueryPort} null ist
*/
public DefaultHistoryDetailsUseCase(HistoryQueryPort historyQueryPort) {
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
}
/**
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit den Detaildaten, oder leer wenn kein Stammsatz gefunden wurde
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
public Optional<HistoryDetailsResult> loadDetails(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
Optional<DocumentRecord> record = historyQueryPort.findRecordByFingerprint(fingerprint);
if (record.isEmpty()) {
return Optional.empty();
}
List<ProcessingAttempt> attempts = historyQueryPort.findAttemptsByFingerprint(fingerprint);
return Optional.of(new HistoryDetailsResult(record.get(), attempts));
}
/**
* Ergebnis einer Historien-Detailabfrage.
*
* @param record Dokument-Stammsatz; nie {@code null}
* @param attempts alle historisierten Verarbeitungsversuche aufsteigend nach Versuchsnummer;
* nie {@code null}; kann leer sein
*/
public record HistoryDetailsResult(DocumentRecord record, List<ProcessingAttempt> attempts) {
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws NullPointerException wenn {@code record} oder {@code attempts} null ist
*/
public HistoryDetailsResult {
Objects.requireNonNull(record, "record darf nicht null sein");
Objects.requireNonNull(attempts, "attempts darf nicht null sein");
}
}
}
@@ -0,0 +1,82 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.List;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
/**
* Use-Case-Implementierung für das Laden der Dokumentenliste im Historien-Tab.
* <p>
* Delegiert die Datenbankabfrage vollständig an {@link HistoryQueryPort} und
* wertet das LIMIT-501-Ergebnis aus, um der GUI signalisieren zu können, ob
* weitere Einträge vorhanden sind, die durch einen engeren Filter erreichbar wären.
* <p>
* <strong>LIMIT-501-Technik:</strong> Die Query wird mit {@code limit + 1 = 501}
* ausgeführt (sofern das übergebene Limit 500 beträgt). Wenn die Datenbank 501
* Zeilen zurückgibt, existieren mehr als 500 Treffer. Die zurückgegebene Liste
* enthält dann exakt 500 Zeilen (das letzte Element wird verworfen) und
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
*/
public class DefaultHistoryOverviewUseCase {
private static final int MAX_DISPLAY_COUNT = 500;
private final HistoryQueryPort historyQueryPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
*
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code historyQueryPort} null ist
*/
public DefaultHistoryOverviewUseCase(HistoryQueryPort historyQueryPort) {
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
}
/**
* Lädt die Dokumentenliste auf Basis der übergebenen Abfrageparameter.
* <p>
* Intern wird ein Limit von 501 verwendet, um erkennen zu können, ob mehr
* als 500 Treffer vorhanden sind.
*
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
* @return Ergebnisobjekt mit Trefferlist und {@code hasMore}-Flag; nie {@code null}
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
public HistoryOverviewResult loadOverview(HistoryQuery query) {
Objects.requireNonNull(query, "query darf nicht null sein");
List<DocumentHistoryRow> rows = historyQueryPort.loadOverview(query);
if (rows.size() > MAX_DISPLAY_COUNT) {
// 501 Zeilen zurückgegeben: mehr als 500 Treffer vorhanden
List<DocumentHistoryRow> truncated = List.copyOf(rows.subList(0, MAX_DISPLAY_COUNT));
return new HistoryOverviewResult(truncated, true);
}
return new HistoryOverviewResult(List.copyOf(rows), false);
}
/**
* Ergebnis einer Historien-Übersichtsabfrage.
*
* @param rows Liste der Trefferzeilen; nie {@code null}; enthält maximal 500 Einträge
* @param hasMore {@code true}, wenn mehr als 500 Treffer vorhanden sind und durch
* einen engeren Filter eingegrenzt werden könnten
*/
public record HistoryOverviewResult(List<DocumentHistoryRow> rows, boolean hasMore) {
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws NullPointerException wenn {@code rows} null ist
*/
public HistoryOverviewResult {
Objects.requireNonNull(rows, "rows darf nicht null sein");
}
}
}
@@ -0,0 +1,69 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Use-Case-Implementierung für den feldgenauen Status-Reset aus dem Historien-Tab.
* <p>
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
* ohne die Versuchshistorie zu löschen:
* <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>
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
* {@code last_target_path}, {@code last_target_file_name} sowie alle
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
* <p>
* Nach dem Reset gilt das Dokument beim nächsten Verarbeitungslauf als verarbeitbar,
* da {@code READY_FOR_AI} der einzige Trigger für die Verarbeitungslogik ist.
* <p>
* <strong>Abgrenzung:</strong> Dieser Use-Case unterscheidet sich von
* {@link DefaultResetDocumentStatusUseCase}, der alle Persistenzdaten (Stammsatz und
* Versuchshistorie) vollständig löscht und das Dokument so behandelt, als wäre es
* noch nie verarbeitet worden.
*/
public class DefaultHistoryResetDocumentStatusUseCase {
private static final Logger logger = LogManager.getLogger(DefaultHistoryResetDocumentStatusUseCase.class);
private final UnitOfWorkPort unitOfWorkPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
*
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
*/
public DefaultHistoryResetDocumentStatusUseCase(UnitOfWorkPort unitOfWorkPort) {
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
}
/**
* Führt den feldgenauen Status-Reset für den angegebenen Fingerprint durch.
* <p>
* Die Operation ist atomar: entweder werden alle vier Felder aktualisiert,
* oder keine Änderung findet statt (Rollback).
*
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
* @throws NullPointerException wenn {@code fingerprint} null ist
*/
public void resetStatus(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentStatusForRetry(fingerprint));
logger.info("Feldgenauer Status-Reset durchgeführt für Fingerprint: {}", fingerprint.sha256Hex());
}
}
@@ -0,0 +1,101 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
/**
* Use-Case zur Anzeige und Bearbeitung des KI-Prompt-Templates über die GUI.
* <p>
* Dieser Use-Case vermittelt zwischen dem GUI-Adapter und dem {@link PromptPort} sowie dem
* {@link ResourceCreationPort}. Er kennt keine JavaFX-Typen, kein Dateisystem und keine
* HTTP-Kommunikation; alle technischen Details bleiben in den jeweiligen Adaptern.
* <p>
* <strong>Verantwortung:</strong>
* <ul>
* <li>Aktuellen Prompt-Inhalt laden und als strukturiertes Ergebnis zurückgeben.</li>
* <li>Bearbeiteten Inhalt atomar in die konfigurierte Prompt-Datei speichern.</li>
* <li>Anlegen einer Standard-Prompt-Datei delegieren, wenn keine Datei vorhanden ist.</li>
* </ul>
* <p>
* <strong>Abgrenzung:</strong> Dieser Use-Case trifft keine Entscheidungen über
* Benutzeroberfläche, Threading oder Dirty-State-Verwaltung. Diese Verantwortung
* liegt im GUI-Adapter.
*/
public class DefaultPromptEditorUseCase {
private final PromptPort promptPort;
private final ResourceCreationPort resourceCreationPort;
/**
* Erstellt den Use-Case mit den erforderlichen Ports.
*
* @param promptPort Port zum Laden und Speichern des Prompt-Templates;
* darf nicht {@code null} sein
* @param resourceCreationPort Port zum Anlegen der Standard-Prompt-Datei;
* darf nicht {@code null} sein
* @throws NullPointerException wenn ein Parameter {@code null} ist
*/
public DefaultPromptEditorUseCase(PromptPort promptPort, ResourceCreationPort resourceCreationPort) {
this.promptPort = Objects.requireNonNull(promptPort, "promptPort must not be null");
this.resourceCreationPort = Objects.requireNonNull(resourceCreationPort,
"resourceCreationPort must not be null");
}
/**
* Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Prompt-Datei.
* <p>
* Delegiert direkt an {@link PromptPort#loadPrompt()} und gibt das Ergebnis
* unverändert zurück.
*
* @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg,
* oder einem klassifizierten Fehler; nie {@code null}
* @see PromptLoadingSuccess
* @see PromptLoadingFailure
*/
public PromptLoadingResult loadPrompt() {
return promptPort.loadPrompt();
}
/**
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
* <p>
* Delegiert direkt an {@link PromptPort#savePrompt(String)}. Der Zielpfad ist
* Implementierungsdetail des Adapters.
*
* @param content der zu speichernde Prompt-Inhalt; darf nicht {@code null} sein
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @throws NullPointerException wenn {@code content} null ist
* @see PromptSaveResult.Saved
* @see PromptSaveResult.WriteFailed
* @see PromptSaveResult.TargetDirectoryMissing
* @see PromptSaveResult.AtomicMoveFailed
*/
public PromptSaveResult savePrompt(String content) {
Objects.requireNonNull(content, "content must not be null");
return promptPort.savePrompt(content);
}
/**
* Legt eine Standard-Prompt-Datei an, wenn noch keine vorhanden ist.
* <p>
* Delegiert an {@link ResourceCreationPort#createPromptFile(CorrectionSuggestion.CreatePromptFile)}.
* Das Ergebnis beschreibt, ob die Datei angelegt wurde, ob sie bereits existierte
* oder ob ein Fehler aufgetreten ist.
*
* @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
*/
public CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion) {
Objects.requireNonNull(suggestion, "suggestion must not be null");
return resourceCreationPort.createPromptFile(suggestion);
}
}
@@ -3,8 +3,12 @@ package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalDocumentContextUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure;
@@ -22,15 +26,21 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* Stammsatz ({@code lastTargetFileName}, {@code lastSuccessInstant}).</li>
* <li>Bei endgültigem Fehlschlag: Fehlzeitpunkt aus dem Stammsatz
* ({@code lastFailureInstant}).</li>
* <li>In allen anderen Fällen (unbekannt, verarbeitbar) sowie bei technischen
* <li>In allen anderen Fällen (unbekannt, verarbeitbar) sowie bei erwarteten technischen
* Abfragefehlern: leeres {@link Optional}.</li>
* </ul>
* Technische Fehler bei der Repository-Abfrage werden intern abgefangen; der Aufrufer
* erhält stets ein leeres Ergebnis statt einer Ausnahme.
* {@link DocumentPersistenceException}s aus dem Repository werden geloggt (WARN) und
* führen zu einem leeren {@link Optional}. Andere unerwartete Laufzeitfehler
* propagieren zum Aufrufer. Erwartete Lookup-Fehler werden als
* {@code PersistenceLookupTechnicalFailure} im Rückgabewert kodiert und führen
* ebenfalls zu einem leeren {@link Optional}.
*/
public class DefaultResolveHistoricalDocumentContextUseCase
implements ResolveHistoricalDocumentContextUseCase {
private static final Logger logger =
LogManager.getLogger(DefaultResolveHistoricalDocumentContextUseCase.class);
private final DocumentRecordRepository documentRecordRepository;
/**
@@ -76,7 +86,9 @@ public class DefaultResolveHistoricalDocumentContextUseCase
failure.record().lastFailureInstant()));
}
return Optional.empty();
} catch (Exception e) {
} catch (DocumentPersistenceException e) {
logger.warn("Persistenzfehler beim Lesen des Dokument-Stammsatzes für Fingerprint {}: {}",
fingerprint, e.getMessage(), e);
return Optional.empty();
}
}
@@ -3,27 +3,36 @@ package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalFileNameUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Default implementation of {@link ResolveHistoricalFileNameUseCase}.
* Standardimplementierung von {@link ResolveHistoricalFileNameUseCase}.
* <p>
* Queries the {@link DocumentRecordRepository} for the master record of the given fingerprint.
* If the record represents a document that previously reached a successful terminal state,
* the last known target filename ({@code lastTargetFileName}) is returned.
* Fragt den {@link DocumentRecordRepository} nach dem Stammsatz des angegebenen
* Fingerprints ab. Ist der Stammsatz terminal erfolgreich, wird der zuletzt
* geschriebene Zieldateiname zurückgegeben.
* <p>
* For all other terminal states (e.g. documents that finally failed without ever producing
* a target copy) or when no master record exists, an empty {@link Optional} is returned.
* Technical failures during the repository lookup are caught silently and treated as
* an absent result so that the calling GUI layer is never forced to handle exceptions
* from this query path.
* Für alle anderen terminalen Zustände oder wenn kein Stammsatz vorhanden ist,
* wird ein leeres {@link Optional} zurückgegeben.
* {@link DocumentPersistenceException}s aus dem Repository werden geloggt (WARN) und
* führen zu einem leeren {@link Optional}. Andere unerwartete Laufzeitfehler
* propagieren zum Aufrufer. Erwartete Lookup-Fehler werden als
* {@code PersistenceLookupTechnicalFailure} im Rückgabewert kodiert und führen
* ebenfalls zu einem leeren {@link Optional}.
*/
public class DefaultResolveHistoricalFileNameUseCase implements ResolveHistoricalFileNameUseCase {
private static final Logger logger =
LogManager.getLogger(DefaultResolveHistoricalFileNameUseCase.class);
private final DocumentRecordRepository documentRecordRepository;
/**
@@ -62,7 +71,9 @@ public class DefaultResolveHistoricalFileNameUseCase implements ResolveHistorica
return Optional.ofNullable(success.record().lastTargetFileName());
}
return Optional.empty();
} catch (Exception e) {
} catch (DocumentPersistenceException e) {
logger.warn("Persistenzfehler beim Lesen des historischen Dateinamens für Fingerprint {}: {}",
fingerprint, e.getMessage(), e);
return Optional.empty();
}
}
@@ -84,5 +84,13 @@ public enum CheckpointId {
* zeigt auf eine vorhandene Datei oder auf einen beschreibbaren Ordner, in dem die
* Datei neu angelegt werden kann.
*/
SQLITE_PATH_USABLE
SQLITE_PATH_USABLE,
/**
* Log-Verzeichnis beschreibbar das konfigurierte (oder Standard-)Log-Verzeichnis
* ist vorhanden und schreibbar. Zeigt zusätzlich den tatsächlichen Log-Dateipfad
* aus der aktiven Log4j2-Konfiguration an. Ein nicht beschreibbares Log-Verzeichnis
* ist eine Warnung, kein harter Fehler.
*/
LOG_DIRECTORY_USABLE
}
@@ -0,0 +1,28 @@
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
import java.util.Optional;
/**
* Ausgehender Port zur Diagnose des aktiven Log-Ausgabepfads.
* <p>
* Implementierungen lesen den tatsächlich von der Logging-Infrastruktur verwendeten
* Dateipfad aus der laufenden Konfiguration des Logging-Frameworks aus. Der Port ist
* provider-neutral; er kennt weder Log4j2 noch andere Framework-spezifische Typen.
* <p>
* Diese Information ergänzt den konfigurierten {@code log.directory}-Wert aus der
* Properties-Datei und zeigt, wo Logeinträge tatsächlich landen unabhängig davon,
* ob das Log-Verzeichnis zum Zeitpunkt des Tests beschreibbar ist.
*/
public interface LogDiagnosticsPort {
/**
* Ermittelt den absoluten Dateipfad der aktiven Log-Ausgabedatei.
* <p>
* Gibt einen leeren {@link Optional} zurück, wenn der Pfad nicht bestimmbar ist
* beispielsweise weil kein dateibasierter Appender aktiv ist oder die
* Logging-Konfiguration nicht ausgelesen werden kann.
*
* @return absoluter Pfad der aktiven Log-Datei; leer wenn nicht bestimmbar
*/
Optional<String> resolveActiveLogFilePath();
}
@@ -17,16 +17,18 @@ import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidation
/**
* Orchestrator für den vollständigen technischen Gesamttest der GUI-Konfiguration.
* <p>
* Führt alle elf definierten Prüfpunkte in drei voneinander unabhängigen Blöcken aus:
* Führt alle zwölf definierten Prüfpunkte in drei voneinander unabhängigen Blöcken aus:
* <ol>
* <li><strong>Lokale Validierung:</strong> Prüft den Editorzustand ohne I/O mithilfe des
* {@link EditorConfigurationValidator}. Erzeugt Ergebnisse für
* {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} und
* {@link CheckpointId#PROVIDER_CONFIGURATION}.</li>
* <li><strong>Pfadprüfungen:</strong> Prüft Quellordner, Zielordner, Prompt-Datei und
* SQLite-Pfad über den {@link PathCheckPort}. Erzeugt Ergebnisse für
* <li><strong>Pfadprüfungen:</strong> Prüft Quellordner, Zielordner, Prompt-Datei,
* SQLite-Pfad und Log-Verzeichnis über den {@link PathCheckPort} sowie den
* {@link LogDiagnosticsPort}. Erzeugt Ergebnisse für
* {@link CheckpointId#PROMPT_FILE_PRESENT}, {@link CheckpointId#SOURCE_FOLDER_PRESENT},
* {@link CheckpointId#TARGET_FOLDER_USABLE} und {@link CheckpointId#SQLITE_PATH_USABLE}.</li>
* {@link CheckpointId#TARGET_FOLDER_USABLE}, {@link CheckpointId#SQLITE_PATH_USABLE}
* und {@link CheckpointId#LOG_DIRECTORY_USABLE}.</li>
* <li><strong>Provider-Prüfungen:</strong> Prüft Endpoint, API-Key, Modellliste und
* Modellplausibilität über den {@link ProviderTechnicalTestService}. Erzeugt Ergebnisse für
* {@link CheckpointId#BASE_URL_REACHABLE}, {@link CheckpointId#API_KEY_PRESENT},
@@ -38,7 +40,7 @@ import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidation
* ausgeführt, auch wenn ein Block eine Exception wirft. In diesem Fall werden die
* betroffenen Checkpoints als {@link CheckpointResult.Failure} mit Schweregrad ERROR
* und dem Präfix Interner Fehler:" markiert. Der Gesamtbericht enthält immer genau
* elf Einträge.
* zwölf Einträge.
* <p>
* <strong>Threading-Kontrakt:</strong> Die Methode {@link #run(TechnicalTestRequest)}
* ist synchron blockierend (der Provider-Prüfblock führt HTTP-Aufrufe durch). Sie darf
@@ -51,28 +53,32 @@ import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidation
* wird {@code config/prompt.txt} relativ zum Arbeitsverzeichnis verwendet.
* <p>
* Dieser Service enthält keine JavaFX-Typen, keine NIO-Pfadobjekte in Signaturen und
* keine Infrastrukturabhängigkeiten jenseits der drei injizierten Abhängigkeiten.
* keine Infrastrukturabhängigkeiten jenseits der vier injizierten Abhängigkeiten.
*/
public class TechnicalTestOrchestrator {
private final EditorConfigurationValidator editorValidator;
private final PathCheckPort pathCheckPort;
private final ProviderTechnicalTestService providerTestService;
private final LogDiagnosticsPort logDiagnosticsPort;
/**
* Erstellt einen neuen Orchestrator mit den drei erforderlichen Abhängigkeiten.
* Erstellt einen neuen Orchestrator mit den vier erforderlichen Abhängigkeiten.
*
* @param editorValidator Lokaler Konfigurationsvalidator; darf nicht {@code null} sein
* @param pathCheckPort Port für Dateisystem-Pfadprüfungen; darf nicht {@code null} sein
* @param providerTestService Service für provider-nahe technische Prüfungen; darf nicht {@code null} sein
* @param logDiagnosticsPort Port zur Auflösung des aktiven Log-Dateipfads; darf nicht {@code null} sein
* @throws NullPointerException wenn einer der Parameter {@code null} ist
*/
public TechnicalTestOrchestrator(EditorConfigurationValidator editorValidator,
PathCheckPort pathCheckPort,
ProviderTechnicalTestService providerTestService) {
ProviderTechnicalTestService providerTestService,
LogDiagnosticsPort logDiagnosticsPort) {
this.editorValidator = Objects.requireNonNull(editorValidator, "editorValidator must not be null");
this.pathCheckPort = Objects.requireNonNull(pathCheckPort, "pathCheckPort must not be null");
this.providerTestService = Objects.requireNonNull(providerTestService, "providerTestService must not be null");
this.logDiagnosticsPort = Objects.requireNonNull(logDiagnosticsPort, "logDiagnosticsPort must not be null");
}
/**
@@ -80,7 +86,7 @@ public class TechnicalTestOrchestrator {
* <p>
* Alle drei Prüfblöcke werden immer vollständig ausgeführt. Ein Fehler in einem Block
* führt nicht dazu, dass ein anderer Block übersprungen wird. Der zurückgegebene Bericht
* enthält immer genau elf {@link CheckpointResult}-Einträge.
* enthält immer genau zwölf {@link CheckpointResult}-Einträge.
* <p>
* <strong>Prompt-Datei-Standardpfad:</strong> Wenn der Editorzustand keinen Prompt-Pfad
* enthält, wird als Standardpfad der Elternordner der Konfigurationsdatei gewählt
@@ -96,7 +102,7 @@ public class TechnicalTestOrchestrator {
* abgeschlossen sind. Sie darf nicht auf dem JavaFX Application Thread aufgerufen werden.
*
* @param request Eingabedaten für den Gesamttest; darf nicht {@code null} sein
* @return vollständiger Gesamttestbericht mit genau elf Einträgen; nie {@code null}
* @return vollständiger Gesamttestbericht mit genau zwölf Einträgen; nie {@code null}
* @throws NullPointerException wenn {@code request} {@code null} ist
*/
public TechnicalTestReport run(TechnicalTestRequest request) {
@@ -104,13 +110,13 @@ public class TechnicalTestOrchestrator {
Instant startTime = Instant.now();
EditorValidationInput input = request.validationInput();
List<CheckpointResult> results = new ArrayList<>(11);
List<CheckpointResult> results = new ArrayList<>(12);
// Block 1: Lokale Konfigurationsvalidierung (kein I/O)
results.addAll(runLocalValidationBlock(input));
// Block 2: Pfadprüfungen (Dateisystem-I/O)
results.addAll(runPathCheckBlock(input, request.configFilePath()));
results.addAll(runPathCheckBlock(input, request.configFilePath(), request.logDirectory()));
// Block 3: Provider-nahe technische Prüfungen (Netzwerk-I/O)
results.addAll(runProviderCheckBlock(input));
@@ -222,25 +228,30 @@ public class TechnicalTestOrchestrator {
// =========================================================================
/**
* Führt die Dateisystem-Pfadprüfungen für Prompt-Datei, Quellordner, Zielordner
* und SQLite-Pfad durch.
* Führt die Dateisystem-Pfadprüfungen für Prompt-Datei, Quellordner, Zielordner,
* SQLite-Pfad und Log-Verzeichnis durch.
* <p>
* Der {@code configFilePath} wird genutzt, um bei fehlendem Prompt-Pfad im Editorzustand
* einen sinnvollen Standardpfad zu bestimmen ({@code <config-parent>/prompt.txt}).
* Der {@code logDirectory} ist der konfigurierte Rohwert von {@code log.directory};
* leer bedeutet Standardwert {@code ./logs/}.
*
* @param input aktueller Editorzustand
* @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
* @return Liste mit genau vier Einträgen
* @param logDirectory konfigurierter Rohwert von {@code log.directory}; leer = Standard
* @return Liste mit genau fünf Einträgen
*/
private List<CheckpointResult> runPathCheckBlock(EditorValidationInput input,
String configFilePath) {
String configFilePath,
String logDirectory) {
try {
List<CheckpointResult> results = new ArrayList<>(4);
List<CheckpointResult> results = new ArrayList<>(5);
results.add(checkPromptFile(input.promptTemplateFile(), configFilePath,
resolveMaxTitleLengthForPromptCreation(input.maxTitleLength())));
results.add(checkSourceFolder(input.sourceFolder()));
results.add(checkTargetFolder(input.targetFolder()));
results.add(checkSqlitePath(input.sqliteFile()));
results.add(checkLogDirectory(logDirectory));
return results;
} catch (Exception e) {
String errorMsg = "Interner Fehler bei den Pfadprüfungen: " + e.getMessage();
@@ -252,6 +263,8 @@ public class TechnicalTestOrchestrator {
CheckpointResult.Failure.of(CheckpointId.TARGET_FOLDER_USABLE,
CheckpointSeverity.ERROR, errorMsg),
CheckpointResult.Failure.of(CheckpointId.SQLITE_PATH_USABLE,
CheckpointSeverity.ERROR, errorMsg),
CheckpointResult.Failure.of(CheckpointId.LOG_DIRECTORY_USABLE,
CheckpointSeverity.ERROR, errorMsg)
);
}
@@ -471,6 +484,64 @@ public class TechnicalTestOrchestrator {
suggestion);
}
/**
* Prüft das Log-Verzeichnis auf Schreibbarkeit und zeigt den tatsächlichen
* Log-Dateipfad aus der aktiven Log4j2-Konfiguration an.
* <p>
* <strong>Verzeichnis-Ermittlung:</strong> Wenn der konfigurierte {@code log.directory}-Wert
* leer ist, wird der Standard {@code ./logs} relativ zum Arbeitsverzeichnis angenommen.
* Der Wert wird zu einem absoluten Pfad aufgelöst.
* <p>
* <strong>Ergebnis:</strong>
* <ul>
* <li>{@link CheckpointResult.Success}: Verzeichnis ist vorhanden und schreibbar.
* Die Meldung enthält den aufgelösten absoluten Pfad sowie sofern ermittelbar
* den tatsächlichen Log-Dateipfad aus Log4j2.</li>
* <li>{@link CheckpointResult.Failure} mit Schweregrad {@link CheckpointSeverity#WARNING}:
* Verzeichnis ist nicht vorhanden oder nicht schreibbar. Ein nicht beschreibbares
* Log-Verzeichnis ist eine Warnung, kein harter Fehler, da die Anwendung auch ohne
* Datei-Logging lauffähig ist. Die Meldung enthält Konfiguration und aufgelösten
* absoluten Pfad als Diagnoseinformation.</li>
* </ul>
*
* @param configuredLogDir konfigurierter Rohwert von {@code log.directory}; leer = Standard
* @return Prüfpunkt-Ergebnis
*/
private CheckpointResult checkLogDirectory(String configuredLogDir) {
String effectiveDir = (configuredLogDir == null || configuredLogDir.isBlank())
? "./logs" : configuredLogDir;
String absolutePath;
try {
absolutePath = Paths.get(effectiveDir).toAbsolutePath().toString();
} catch (java.nio.file.InvalidPathException e) {
return CheckpointResult.Failure.of(CheckpointId.LOG_DIRECTORY_USABLE,
CheckpointSeverity.WARNING,
"Log-Verzeichnis: ungültiger Pfad: " + effectiveDir);
}
boolean configuredExplicitly = configuredLogDir != null && !configuredLogDir.isBlank();
String configLabel = configuredExplicitly
? "konfiguriert: " + configuredLogDir + ""
: "Standard → ";
java.util.Optional<String> activeLogFile = logDiagnosticsPort.resolveActiveLogFilePath();
String logFileInfo = activeLogFile
.map(p -> " | Aktive Log-Datei: " + p)
.orElse("");
if (pathCheckPort.isDirectoryWritableOrCreatable(absolutePath)) {
return new CheckpointResult.Success(CheckpointId.LOG_DIRECTORY_USABLE,
"Log-Verzeichnis beschreibbar (" + configLabel + absolutePath + ")" + logFileInfo);
}
return CheckpointResult.Failure.of(CheckpointId.LOG_DIRECTORY_USABLE,
CheckpointSeverity.WARNING,
"Log-Verzeichnis nicht beschreibbar (" + configLabel + absolutePath + ")"
+ logFileInfo
+ ". Tipp: Absoluten Pfad mit Forward-Slashes verwenden, z. B. C:/Benutzer/Logs");
}
// =========================================================================
// Block 3: Provider-nahe technische Prüfungen
// =========================================================================
@@ -15,36 +15,44 @@ import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidation
* Gesamttest, bei der automatischen Prompt-Erzeugung den Standardpfad relativ zur
* Konfigurationsdatei zu bestimmen. Er ist leer, wenn keine Konfigurationsdatei geladen ist.
* <p>
* Das {@code logDirectory}-Feld trägt den konfigurierten Rohwert von {@code log.directory}
* aus dem Editor; leer bedeutet Standardwert {@code ./logs/}.
* <p>
* Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
*
* @param validationInput aktueller Editorzustand; nie {@code null}
* @param configFilePath optionaler Pfad der geladenen Konfigurationsdatei als String;
* leer wenn keine Datei geladen ist
* @param logDirectory konfigurierter Rohwert von {@code log.directory};
* leer wenn kein Wert konfiguriert ist (Standard {@code ./logs/})
*/
public record TechnicalTestRequest(
EditorValidationInput validationInput,
String configFilePath) {
String configFilePath,
String logDirectory) {
/**
* Erstellt eine neue Gesamttest-Anforderung.
*
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
* @param configFilePath Pfad der Konfigurationsdatei; {@code null} wird zu leerem String
* @param logDirectory Rohwert von {@code log.directory}; {@code null} wird zu leerem String
* @throws NullPointerException wenn {@code validationInput} {@code null} ist
*/
public TechnicalTestRequest {
Objects.requireNonNull(validationInput, "validationInput must not be null");
configFilePath = configFilePath == null ? "" : configFilePath;
logDirectory = logDirectory == null ? "" : logDirectory;
}
/**
* Erstellt eine Anforderung ohne geladene Konfigurationsdatei.
* Erstellt eine Anforderung ohne geladene Konfigurationsdatei und ohne Log-Verzeichnis-Angabe.
*
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
* @return eine neue Anforderung ohne Konfigurationsdateipfad
*/
public static TechnicalTestRequest of(EditorValidationInput validationInput) {
return new TechnicalTestRequest(validationInput, "");
return new TechnicalTestRequest(validationInput, "", "");
}
/**
@@ -154,13 +154,36 @@ class DocumentProcessingCoordinatorTest {
}
@Test
void process_newDocument_firstContentError_persistsFailedRetryable_contentCounterOne() {
void process_newDocument_noUsableText_persistsFailedFinal_contentCounterOne() {
// NO_USABLE_TEXT (image-only PDF) finalises immediately no retry.
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckFailed(
candidate, PreCheckFailureReason.NO_USABLE_TEXT);
processor.process(candidate, fingerprint, outcome, context, attemptStart);
assertEquals(1, attemptRepo.savedAttempts.size());
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
assertEquals(ProcessingStatus.FAILED_FINAL, attempt.status());
assertFalse(attempt.retryable());
assertEquals(1, recordRepo.createdRecords.size());
DocumentRecord record = recordRepo.createdRecords.get(0);
assertEquals(ProcessingStatus.FAILED_FINAL, record.overallStatus());
assertEquals(1, record.failureCounters().contentErrorCount());
assertEquals(0, record.failureCounters().transientErrorCount());
assertNotNull(record.lastFailureInstant());
assertNull(record.lastSuccessInstant());
}
@Test
void process_newDocument_firstPageLimitExceeded_persistsFailedRetryable_contentCounterOne() {
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckFailed(
candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
processor.process(candidate, fingerprint, outcome, context, attemptStart);
assertEquals(1, attemptRepo.savedAttempts.size());
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, attempt.status());
@@ -1191,17 +1214,18 @@ class DocumentProcessingCoordinatorTest {
// -------------------------------------------------------------------------
@Test
void process_contentErrorLifecycle_firstRunRetryable_secondRunFinal_thirdRunSkipped() {
// Run 1: new document, first deterministic content error FAILED_RETRYABLE
void process_contentErrorLifecycle_pageLimitExceeded_firstRunRetryable_secondRunFinal_thirdRunSkipped() {
// PAGE_LIMIT_EXCEEDED follows the 1-retry rule: first run FAILED_RETRYABLE, second FAILED_FINAL.
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome contentError = new PreCheckFailed(
candidate, PreCheckFailureReason.NO_USABLE_TEXT);
candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
// Run 1: new document, first content error FAILED_RETRYABLE
processor.process(candidate, fingerprint, contentError, context, attemptStart);
DocumentRecord afterRun1 = recordRepo.createdRecords.get(0);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, afterRun1.overallStatus(),
"First content error must yield FAILED_RETRYABLE");
"First PAGE_LIMIT_EXCEEDED must yield FAILED_RETRYABLE");
assertEquals(1, afterRun1.failureCounters().contentErrorCount());
assertTrue(attemptRepo.savedAttempts.get(0).retryable(),
"First content error attempt must be retryable");
@@ -1236,6 +1260,36 @@ class DocumentProcessingCoordinatorTest {
"Transient error counter must remain 0 after a SKIPPED_FINAL_FAILURE event");
}
@Test
void process_contentErrorLifecycle_noUsableText_immediatelyFinal_secondRunSkipped() {
// NO_USABLE_TEXT (image-only PDF): first run is immediately FAILED_FINAL, second is skipped.
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome noTextError = new PreCheckFailed(
candidate, PreCheckFailureReason.NO_USABLE_TEXT);
// Run 1: new document FAILED_FINAL immediately
processor.process(candidate, fingerprint, noTextError, context, attemptStart);
DocumentRecord afterRun1 = recordRepo.createdRecords.get(0);
assertEquals(ProcessingStatus.FAILED_FINAL, afterRun1.overallStatus(),
"NO_USABLE_TEXT must yield FAILED_FINAL immediately");
assertEquals(1, afterRun1.failureCounters().contentErrorCount());
assertFalse(attemptRepo.savedAttempts.get(0).retryable());
// Run 2: terminal FAILED_FINAL SKIPPED_FINAL_FAILURE; counters must not change
recordRepo.setLookupResult(new DocumentTerminalFinalFailure(afterRun1));
processor.process(candidate, fingerprint, noTextError, context, attemptStart);
assertEquals(2, attemptRepo.savedAttempts.size());
ProcessingAttempt skipAttempt = attemptRepo.savedAttempts.get(1);
assertEquals(ProcessingStatus.SKIPPED_FINAL_FAILURE, skipAttempt.status());
DocumentRecord afterRun2 = recordRepo.updatedRecords.get(0);
assertEquals(1, afterRun2.failureCounters().contentErrorCount(),
"Content error counter must remain 1 after SKIPPED_FINAL_FAILURE");
}
@Test
void process_transientErrorLifecycle_maxRetriesTransient2_firstRetryable_secondFinal() {
// maxRetriesTransient=2: first transient error FAILED_RETRYABLE, second FAILED_FINAL
@@ -1416,6 +1470,11 @@ class DocumentProcessingCoordinatorTest {
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// No-op in tests
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
// No-op in tests
}
};
operations.accept(mockOps);
@@ -1589,8 +1648,9 @@ class DocumentProcessingCoordinatorTest {
@Test
void process_firstContentError_retryDecisionLog_containsFingerprintAndFailedRetryable() {
// Proves that the retry decision log for a first deterministic content error contains
// Proves that the retry decision log for a first retryable content error contains
// both the document fingerprint and the FAILED_RETRYABLE classification.
// Uses PAGE_LIMIT_EXCEEDED which follows the 1-retry rule.
MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturing =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
@@ -1599,7 +1659,7 @@ class DocumentProcessingCoordinatorTest {
recordRepo.setLookupResult(new DocumentUnknown());
coordinatorWithCapturing.process(candidate, fingerprint,
new PreCheckFailed(candidate, PreCheckFailureReason.NO_USABLE_TEXT),
new PreCheckFailed(candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED),
context, attemptStart);
assertTrue(capturingLogger.anyWarnContains(FINGERPRINT_HEX),
@@ -103,13 +103,28 @@ class ProcessingOutcomeTransitionTest {
// -------------------------------------------------------------------------
@Test
void forNewDocument_firstPreCheckFailed_returnsFailedRetryable_contentCounterOne() {
void forNewDocument_noUsableText_immediatelyFailedFinal_noRetry() {
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus());
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
"NO_USABLE_TEXT must finalise immediately without retry");
assertFalse(result.retryable());
assertEquals(1, result.counters().contentErrorCount());
assertEquals(0, result.counters().transientErrorCount());
}
@Test
void forNewDocument_firstPageLimitExceeded_returnsFailedRetryable_contentCounterOne() {
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(),
"PAGE_LIMIT_EXCEEDED first occurrence must be retryable");
assertTrue(result.retryable());
assertEquals(1, result.counters().contentErrorCount());
assertEquals(0, result.counters().transientErrorCount());
@@ -149,9 +164,10 @@ class ProcessingOutcomeTransitionTest {
@Test
void forNewDocument_contentError_transientCounterIsIrrelevant() {
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);
// PAGE_LIMIT_EXCEEDED is used here: it follows the 1-retry rule, and a non-zero
// transient counter must not influence the content-error decision.
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
// Counter before: 0 content errors (first occurrence), transient ignored
ProcessingOutcomeTransition.ProcessingOutcome result =
ProcessingOutcomeTransition.forKnownDocument(
outcome, new FailureCounters(0, 5), LIMIT_1);
@@ -1062,8 +1062,16 @@ class BatchRunProcessingUseCaseTest {
private static AiNamingService buildStubAiNamingService() {
AiInvocationPort stubAiPort = request ->
new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
PromptPort stubPromptPort = () ->
new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
PromptPort stubPromptPort = new PromptPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path");
}
};
ClockPort stubClock = () -> java.time.Instant.EPOCH;
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH);
return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000,
@@ -1388,6 +1396,11 @@ class BatchRunProcessingUseCaseTest {
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// No-op
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
// No-op
}
});
}
}
@@ -1596,6 +1609,11 @@ class BatchRunProcessingUseCaseTest {
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// No-op in tests
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
// No-op in tests
}
});
}
}
@@ -279,8 +279,16 @@ class BatchRunProgressObservationTest {
AiInvocationPort stubAi = req -> {
throw new IllegalStateException("AI must not be invoked in these tests");
};
PromptPort stubPrompt = () -> new PromptLoadingSuccess(
new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
PromptPort stubPrompt = new PromptPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path");
}
};
ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE);
@@ -0,0 +1,144 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Tests für {@link DefaultDeleteDocumentHistoryUseCase}.
* <p>
* Prüft, dass ausschließlich {@code resetDocumentByFingerprint} aufgerufen wird
* (vollständige Löschung inklusive Versuchen, FK-sicher), Null-Guards greifen
* und Port-Fehler propagiert werden.
*/
class DefaultDeleteDocumentHistoryUseCaseTest {
private static final DocumentFingerprint FP =
new DocumentFingerprint("b".repeat(64));
// -------------------------------------------------------------------------
// Null-Guards
// -------------------------------------------------------------------------
@Test
void constructor_nullPort_throwsNPE() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultDeleteDocumentHistoryUseCase(null));
}
@Test
void deleteHistory_nullFingerprint_throwsNPE() {
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(noOpPort());
assertThatNullPointerException()
.isThrownBy(() -> useCase.deleteHistory(null));
}
// -------------------------------------------------------------------------
// Happy path: vollständige Löschung
// -------------------------------------------------------------------------
@Test
void deleteHistory_callsResetDocumentByFingerprint() {
RecordingTransactionOperations ops = new RecordingTransactionOperations();
UnitOfWorkPort port = operations -> operations.accept(ops);
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(port);
useCase.deleteHistory(FP);
assertThat(ops.resetByFingerprintFingerprints)
.containsExactly(FP);
}
@Test
void deleteHistory_doesNotCallResetDocumentStatusForRetry() {
RecordingTransactionOperations ops = new RecordingTransactionOperations();
UnitOfWorkPort port = operations -> operations.accept(ops);
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(port);
useCase.deleteHistory(FP);
assertThat(ops.resetStatusForRetryFingerprints).isEmpty();
}
// -------------------------------------------------------------------------
// Port-Fehler wird propagiert
// -------------------------------------------------------------------------
@Test
void deleteHistory_portThrows_exceptionPropagated() {
UnitOfWorkPort failingPort = operations ->
operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override
public void saveProcessingAttempt(ProcessingAttempt attempt) { }
@Override
public void createDocumentRecord(DocumentRecord record) { }
@Override
public void updateDocumentRecord(DocumentRecord record) { }
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
throw new DocumentPersistenceException("Simulated DB error");
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
});
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(failingPort);
assertThatThrownBy(() -> useCase.deleteHistory(FP))
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("Simulated DB error");
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static UnitOfWorkPort noOpPort() {
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
@Override public void createDocumentRecord(DocumentRecord r) { }
@Override public void updateDocumentRecord(DocumentRecord r) { }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
});
}
/**
* Zeichnet {@code resetDocumentByFingerprint}- und {@code resetDocumentStatusForRetry}-Aufrufe auf.
*/
private static class RecordingTransactionOperations
implements UnitOfWorkPort.TransactionOperations {
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
@Override public void createDocumentRecord(DocumentRecord r) { }
@Override public void updateDocumentRecord(DocumentRecord r) { }
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
resetByFingerprintFingerprints.add(fingerprint);
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
resetStatusForRetryFingerprints.add(fingerprint);
}
}
}
@@ -0,0 +1,215 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
* Tests für {@link DefaultHistoryDetailsUseCase}.
* <p>
* Prüft den Happy-Path (Stammsatz vorhanden), das leere-Optional-Verhalten
* (kein Stammsatz), Null-Guards und Port-Fehler-Propagation.
*/
class DefaultHistoryDetailsUseCaseTest {
private static final DocumentFingerprint FP =
new DocumentFingerprint("a".repeat(64));
// -------------------------------------------------------------------------
// Null-Guards
// -------------------------------------------------------------------------
@Test
void constructor_nullPort_throwsNPE() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultHistoryDetailsUseCase(null));
}
@Test
void loadDetails_nullFingerprint_throwsNPE() {
DefaultHistoryDetailsUseCase useCase =
new DefaultHistoryDetailsUseCase(emptyPort());
assertThatNullPointerException()
.isThrownBy(() -> useCase.loadDetails(null));
}
// -------------------------------------------------------------------------
// Kein Stammsatz vorhanden
// -------------------------------------------------------------------------
@Test
void loadDetails_noRecord_returnsEmpty() {
DefaultHistoryDetailsUseCase useCase =
new DefaultHistoryDetailsUseCase(emptyPort());
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
assertThat(result).isEmpty();
}
// -------------------------------------------------------------------------
// Happy path: Stammsatz vorhanden, Versuche vorhanden
// -------------------------------------------------------------------------
@Test
void loadDetails_recordExists_returnsResultWithRecordAndAttempts() {
DocumentRecord record = buildRecord(FP);
ProcessingAttempt attempt = buildAttempt(FP);
HistoryQueryPort port = new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return Collections.emptyList();
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.of(record);
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return List.of(attempt);
}
};
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
assertThat(result).isPresent();
assertThat(result.get().record()).isSameAs(record);
assertThat(result.get().attempts()).containsExactly(attempt);
}
// -------------------------------------------------------------------------
// Stammsatz vorhanden, keine Versuche
// -------------------------------------------------------------------------
@Test
void loadDetails_recordExistsNoAttempts_returnsResultWithEmptyAttempts() {
DocumentRecord record = buildRecord(FP);
HistoryQueryPort port = new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return Collections.emptyList();
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.of(record);
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
assertThat(result).isPresent();
assertThat(result.get().attempts()).isEmpty();
}
// -------------------------------------------------------------------------
// Port-Fehler wird propagiert
// -------------------------------------------------------------------------
@Test
void loadDetails_portThrowsOnRecord_exceptionPropagated() {
HistoryQueryPort failingPort = new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return Collections.emptyList();
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
throw new DocumentPersistenceException("Simulated DB error");
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(failingPort);
assertThatThrownBy(() -> useCase.loadDetails(FP))
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("Simulated DB error");
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static HistoryQueryPort emptyPort() {
return new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return Collections.emptyList();
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.empty();
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
}
private static DocumentRecord buildRecord(DocumentFingerprint fp) {
return new DocumentRecord(
fp,
new SourceDocumentLocator("/source"),
"source.pdf",
ProcessingStatus.SUCCESS,
new FailureCounters(0, 0),
null,
Instant.now(),
Instant.now(),
Instant.now(),
"/target",
"2024-01-01 - Dokument.pdf");
}
private static ProcessingAttempt buildAttempt(DocumentFingerprint fp) {
return ProcessingAttempt.withoutAiFields(
fp,
new de.gecheckt.pdf.umbenenner.domain.model.RunId(
java.util.UUID.randomUUID().toString()),
1,
Instant.now(),
Instant.now(),
ProcessingStatus.SUCCESS,
null,
null,
false);
}
}
@@ -0,0 +1,199 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
/**
* Tests für {@link DefaultHistoryOverviewUseCase}.
* <p>
* Prüft den Happy-Path, das LIMIT-501-Verhalten und Null-Guards.
*/
class DefaultHistoryOverviewUseCaseTest {
private static final DocumentFingerprint FP =
new DocumentFingerprint("a".repeat(64));
// -------------------------------------------------------------------------
// Null-Guards
// -------------------------------------------------------------------------
@Test
void constructor_nullPort_throwsNPE() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultHistoryOverviewUseCase(null));
}
@Test
void loadOverview_nullQuery_throwsNPE() {
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(emptyPort());
assertThatNullPointerException()
.isThrownBy(() -> useCase.loadOverview(null));
}
// -------------------------------------------------------------------------
// Happy path: leer
// -------------------------------------------------------------------------
@Test
void loadOverview_emptyDatabase_returnsEmptyResultWithoutMore() {
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(emptyPort());
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThat(result.rows()).isEmpty();
assertThat(result.hasMore()).isFalse();
}
// -------------------------------------------------------------------------
// Happy path: weniger als 500 Treffer
// -------------------------------------------------------------------------
@Test
void loadOverview_fewerThan500Results_returnsAllRowsWithoutMore() {
List<DocumentHistoryRow> rows = buildRows(10);
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(fixedPort(rows));
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThat(result.rows()).hasSize(10);
assertThat(result.hasMore()).isFalse();
}
// -------------------------------------------------------------------------
// LIMIT-501-Technik
// -------------------------------------------------------------------------
@Test
void loadOverview_exactly500Results_returnsAllWithoutMore() {
List<DocumentHistoryRow> rows = buildRows(500);
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(fixedPort(rows));
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThat(result.rows()).hasSize(500);
assertThat(result.hasMore()).isFalse();
}
@Test
void loadOverview_moreThan500Results_returns500RowsWithHasMore() {
List<DocumentHistoryRow> rows = buildRows(501);
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(fixedPort(rows));
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThat(result.rows()).hasSize(500);
assertThat(result.hasMore()).isTrue();
}
@Test
void loadOverview_resultListIsImmutable() {
List<DocumentHistoryRow> rows = buildRows(3);
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(fixedPort(rows));
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThatThrownBy(() -> result.rows().add(buildRow("0".repeat(64))))
.isInstanceOf(UnsupportedOperationException.class);
}
// -------------------------------------------------------------------------
// Port-Fehler wird propagiert
// -------------------------------------------------------------------------
@Test
void loadOverview_portThrows_exceptionPropagated() {
HistoryQueryPort failingPort = new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
throw new DocumentPersistenceException("Simulated DB error");
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.empty();
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(failingPort);
assertThatThrownBy(() -> useCase.loadOverview(HistoryQuery.unfiltered()))
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("Simulated DB error");
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static HistoryQueryPort emptyPort() {
return fixedPort(Collections.emptyList());
}
private static HistoryQueryPort fixedPort(List<DocumentHistoryRow> rows) {
return new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return new ArrayList<>(rows);
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.empty();
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
}
private static List<DocumentHistoryRow> buildRows(int count) {
List<DocumentHistoryRow> result = new ArrayList<>();
for (int i = 0; i < count; i++) {
String hex = String.format("%064x", i);
result.add(buildRow(hex));
}
return result;
}
private static DocumentHistoryRow buildRow(String fpHex) {
return new DocumentHistoryRow(
new DocumentFingerprint(fpHex),
ProcessingStatus.SUCCESS,
"source.pdf",
"2024-01-01 - Dokument.pdf",
"/source",
Instant.now(),
1L);
}
}
@@ -0,0 +1,147 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Tests für {@link DefaultHistoryResetDocumentStatusUseCase}.
* <p>
* Prüft, dass ausschließlich {@code resetDocumentStatusForRetry} aufgerufen wird
* (nicht {@code resetDocumentByFingerprint}), Null-Guards greifen und
* Port-Fehler propagiert werden.
*/
class DefaultHistoryResetDocumentStatusUseCaseTest {
private static final DocumentFingerprint FP =
new DocumentFingerprint("a".repeat(64));
// -------------------------------------------------------------------------
// Null-Guards
// -------------------------------------------------------------------------
@Test
void constructor_nullPort_throwsNPE() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultHistoryResetDocumentStatusUseCase(null));
}
@Test
void resetStatus_nullFingerprint_throwsNPE() {
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(noOpPort());
assertThatNullPointerException()
.isThrownBy(() -> useCase.resetStatus(null));
}
// -------------------------------------------------------------------------
// Happy path: feldgenauer Reset
// -------------------------------------------------------------------------
@Test
void resetStatus_callsResetDocumentStatusForRetry() {
RecordingTransactionOperations ops = new RecordingTransactionOperations();
UnitOfWorkPort port = operations -> operations.accept(ops);
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(port);
useCase.resetStatus(FP);
assertThat(ops.resetStatusForRetryFingerprints)
.containsExactly(FP);
assertThat(ops.resetByFingerprintFingerprints)
.isEmpty();
}
@Test
void resetStatus_doesNotCallResetDocumentByFingerprint() {
RecordingTransactionOperations ops = new RecordingTransactionOperations();
UnitOfWorkPort port = operations -> operations.accept(ops);
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(port);
useCase.resetStatus(FP);
assertThat(ops.resetByFingerprintFingerprints).isEmpty();
}
// -------------------------------------------------------------------------
// Port-Fehler wird propagiert
// -------------------------------------------------------------------------
@Test
void resetStatus_portThrows_exceptionPropagated() {
UnitOfWorkPort failingPort = operations ->
operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override
public void saveProcessingAttempt(ProcessingAttempt attempt) { }
@Override
public void createDocumentRecord(DocumentRecord record) { }
@Override
public void updateDocumentRecord(DocumentRecord record) { }
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
throw new DocumentPersistenceException("Simulated DB error");
}
});
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(failingPort);
assertThatThrownBy(() -> useCase.resetStatus(FP))
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("Simulated DB error");
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static UnitOfWorkPort noOpPort() {
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
@Override public void createDocumentRecord(DocumentRecord r) { }
@Override public void updateDocumentRecord(DocumentRecord r) { }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
});
}
/**
* Zeichnet {@code resetDocumentStatusForRetry}- und {@code resetDocumentByFingerprint}-Aufrufe auf.
*/
private static class RecordingTransactionOperations
implements UnitOfWorkPort.TransactionOperations {
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
@Override public void createDocumentRecord(DocumentRecord r) { }
@Override public void updateDocumentRecord(DocumentRecord r) { }
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
resetByFingerprintFingerprints.add(fingerprint);
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
resetStatusForRetryFingerprints.add(fingerprint);
}
}
}

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