diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index b820ef6..e360e11 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -429,7 +429,13 @@ public final class GuiConfigurationEditorWorkspace { private final GuiBatchRunTab batchRunTab; /** - * Dritter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt + * 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; @@ -518,6 +524,14 @@ 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 { @@ -1296,7 +1310,7 @@ public final class GuiConfigurationEditorWorkspace { scrollPane.setPadding(new Insets(0)); editorTab.setContent(scrollPane); - tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), promptEditorTab.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 diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index c652e47..b51976e 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java @@ -11,6 +11,10 @@ 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.ResetDocumentStatusResult; @@ -68,7 +72,11 @@ public record GuiStartupContext( GuiManualFileCopyPort manualFileCopyPort, GuiHistoricalDocumentContextPort historicalDocumentContextPort, String applicationVersion, - GuiPromptEditorPort promptEditorPort) { + GuiPromptEditorPort promptEditorPort, + GuiHistoryOverviewPort historyOverviewPort, + GuiHistoryDetailsPort historyDetailsPort, + GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort, + GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort) { /** * Creates a fully wired startup context. @@ -134,6 +142,14 @@ public record GuiStartupContext( // 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"); } /** @@ -175,7 +191,9 @@ public record GuiStartupContext( technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort()); + noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(), + noOpHistoryOverviewPort(), noOpHistoryDetailsPort(), + noOpHistoryResetPort(), noOpDeleteHistoryPort()); } /** @@ -211,7 +229,9 @@ public record GuiStartupContext( technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort()); + noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(), + noOpHistoryOverviewPort(), noOpHistoryDetailsPort(), + noOpHistoryResetPort(), noOpDeleteHistoryPort()); } /** @@ -247,7 +267,9 @@ public record GuiStartupContext( technicalTestOrchestrator, correctionExecutionService, rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort()); + noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(), + noOpHistoryOverviewPort(), noOpHistoryDetailsPort(), + noOpHistoryResetPort(), noOpDeleteHistoryPort()); } private static GuiBatchRunLauncher rejectingBatchRunLauncher() { @@ -363,7 +385,11 @@ public record GuiStartupContext( rejectingManualFileCopyPort(), noOpHistoricalDocumentContextPort(), "dev", - noOpPromptEditorPort()); + noOpPromptEditorPort(), + noOpHistoryOverviewPort(), + noOpHistoryDetailsPort(), + noOpHistoryResetPort(), + noOpDeleteHistoryPort()); } private static GuiPromptEditorPort noOpPromptEditorPort() { @@ -391,4 +417,25 @@ public record GuiStartupContext( } }; } + + 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 */ }; + } } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiDeleteDocumentHistoryPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiDeleteDocumentHistoryPort.java new file mode 100644 index 0000000..0f66050 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiDeleteDocumentHistoryPort.java @@ -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}. + *

+ * 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. + *

+ * Die GUI muss vor dem Aufruf dieses Ports einen Bestätigungsdialog mit + * explizitem Warnhinweis anzeigen. + *

+ * Threading: 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. + *

+ * 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); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryDetailsPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryDetailsPort.java new file mode 100644 index 0000000..c8e2491 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryDetailsPort.java @@ -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}. + *

+ * Dieses Interface ist kein 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. + *

+ * Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung + * die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann. + *

+ * Threading: 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 loadDetails(Path configFilePath, DocumentFingerprint fingerprint); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryOverviewPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryOverviewPort.java new file mode 100644 index 0000000..9972945 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryOverviewPort.java @@ -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}. + *

+ * Dieses Interface ist kein 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. + *

+ * 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. + *

+ * Threading: 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. + *

+ * 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); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryResetDocumentStatusPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryResetDocumentStatusPort.java new file mode 100644 index 0000000..b6e06f0 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryResetDocumentStatusPort.java @@ -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}. + *

+ * 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. + *

+ * Abgrenzung zu {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort}: + * 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. + *

+ * Threading: 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. + *

+ * Folgende Felder werden aktualisiert: + *

+ * + * @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); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java new file mode 100644 index 0000000..9fc3191 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java @@ -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". + *

+ * 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%). + * + *

Layout

+ *
+ *   ┌─────────────────────────────────────────────────────────────────┐
+ *   │ [ Suchfeld                   ] [ Status ▾ ] [ Aktualisieren ]   │
+ *   ├────────────────────────┬────────────────────────────────────────┤
+ *   │ Dokumentenliste (~55%) │ Detailbereich (~45%)                   │
+ *   │                        │   Dokument-Info                        │
+ *   │                        │   Versuche-Tabelle                     │
+ *   │                        │   KI-Begründung                        │
+ *   ├────────────────────────┴────────────────────────────────────────┤
+ *   │ [ Status zurücksetzen ] [ Eintrag löschen ]   Statuszeile       │
+ *   └─────────────────────────────────────────────────────────────────┘
+ * 
+ * + *

Threading

+ *

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 configPathSupplier; + + // ---- JavaFX-Knoten -------------------------------------------------- + private final Tab tab = new Tab(TAB_TITLE); + + private final TextField searchField = new TextField(); + private final ComboBox statusFilterBox = new ComboBox<>(); + private final Button refreshButton = new Button("Aktualisieren"); + + private final TableView overviewTable = new TableView<>(); + private final ObservableList 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 attemptsTable = new TableView<>(); + private final ObservableList 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 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 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 sourceCol = new TableColumn<>("Quelldatei"); + sourceCol.setCellValueFactory(cell -> + new SimpleStringProperty(cell.getValue().sourceFileName())); + sourceCol.setCellFactory(col -> ellipsisCell()); + + // Zieldateiname + TableColumn targetCol = new TableColumn<>("Zieldatei"); + targetCol.setCellValueFactory(cell -> + new SimpleStringProperty( + cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : "—")); + targetCol.setCellFactory(col -> ellipsisCell()); + + // Letzter Versuch + TableColumn updatedCol = new TableColumn<>("Letzter Versuch"); + updatedCol.setCellValueFactory(cell -> + new SimpleStringProperty(formatInstant(cell.getValue().updatedAt()))); + updatedCol.setPrefWidth(140); + updatedCol.setMaxWidth(160); + + // Anzahl Versuche + TableColumn 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 numCol = new TableColumn<>("#"); + numCol.setCellValueFactory(c -> + new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber()))); + numCol.setPrefWidth(40); + numCol.setMaxWidth(50); + + TableColumn dateCol = new TableColumn<>("Datum"); + dateCol.setCellValueFactory(c -> + new SimpleStringProperty(formatInstant(c.getValue().endedAt()))); + dateCol.setPrefWidth(130); + dateCol.setMaxWidth(150); + + TableColumn statusCol = new TableColumn<>("Status"); + statusCol.setCellValueFactory(c -> + new SimpleStringProperty( + statusIcon(c.getValue().status()) + " " + c.getValue().status().name())); + statusCol.setPrefWidth(140); + + TableColumn providerCol = new TableColumn<>("Provider"); + providerCol.setCellValueFactory(c -> + new SimpleStringProperty( + c.getValue().aiProvider() != null ? c.getValue().aiProvider() : "—")); + providerCol.setPrefWidth(90); + + TableColumn modelCol = new TableColumn<>("Modell"); + modelCol.setCellValueFactory(c -> + new SimpleStringProperty( + c.getValue().modelName() != null ? c.getValue().modelName() : "—")); + modelCol.setCellFactory(col -> ellipsisCell()); + + TableColumn 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 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 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 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 TableCell 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(); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/package-info.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/package-info.java new file mode 100644 index 0000000..0e54cec --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/package-info.java @@ -0,0 +1,15 @@ +/** + * GUI-Adapter für den Historien-Tab. + *

+ * 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}. + *

+ * 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; diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java index 01d3ccf..6239f66 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java @@ -244,14 +244,16 @@ class GuiAdapterSmokeTest { "The 'Speichern' button must be visible"); assertEquals("Speichern unter", workspace.saveAsButton().getText(), "The 'Speichern unter' button must be visible"); - assertEquals(3, workspace.tabPane().getTabs().size(), - "Configuration tab, processing-run tab and prompt editor tab must all 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("Prompt", workspace.tabPane().getTabs().get(2).getText(), - "The third tab must host the prompt editor"); + 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()), diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiHistoryTabSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiHistoryTabSmokeTest.java new file mode 100644 index 0000000..87e669d --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiHistoryTabSmokeTest.java @@ -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}. + *

+ * Geprüfte Szenarien: + *

+ */ +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 fxError = new AtomicReference<>(); + AtomicReference 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 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 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 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()); + } + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java new file mode 100644 index 0000000..dbe419e --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java @@ -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}. + *

+ * 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. + *

+ * Suche: Freitextsuche ist case-insensitiv (via {@code LOWER()}). + * Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem + * SQL-LIKE-Aufruf mit {@code \} escaped. + *

+ * Sortierung: Standard absteigend nach {@code updated_at}, + * Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar). + *

+ * Limit: 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} + *

+ * 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 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 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 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 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 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 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 \}. + *

+ * 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. + *

+ * 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. + *

+ * 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; + } + } + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java index 5e3fd20..d318c0a 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java @@ -171,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 @@ -181,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 @@ -191,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. + *

+ * 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. + *

+ * 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); + } + } } } \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java index e187e4c..e65b537 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java @@ -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. + *

+ * Folgende Felder werden aktualisiert: + *

    + *
  • {@code overall_status} → {@code READY_FOR_AI}
  • + *
  • {@code content_error_count} → {@code 0}
  • + *
  • {@code transient_error_count} → {@code 0}
  • + *
  • {@code last_failure_instant} → {@code null}
  • + *
+ * 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. + *

+ * 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); } } \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/DocumentHistoryRow.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/DocumentHistoryRow.java new file mode 100644 index 0000000..419bb53 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/DocumentHistoryRow.java @@ -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. + *

+ * 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 >= 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); + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/HistoryQuery.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/HistoryQuery.java new file mode 100644 index 0000000..7945f91 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/HistoryQuery.java @@ -0,0 +1,65 @@ +package de.gecheckt.pdf.umbenenner.application.port.out.history; + +/** + * Abfrageparameter für den Historien-Tab. + *

+ * 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 >= 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); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/HistoryQueryPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/HistoryQueryPort.java new file mode 100644 index 0000000..7a6fe22 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/HistoryQueryPort.java @@ -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. + *

+ * 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. + * + *

Architektur

+ *

+ * 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. + *

+ * 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 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 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 findAttemptsByFingerprint(DocumentFingerprint fingerprint); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/package-info.java new file mode 100644 index 0000000..eb21130 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/history/package-info.java @@ -0,0 +1,12 @@ +/** + * Outbound-Ports und DTOs für lesende Historien-Abfragen des Historien-Tabs. + *

+ * 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; diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultDeleteDocumentHistoryUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultDeleteDocumentHistoryUseCase.java new file mode 100644 index 0000000..ae8f22b --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultDeleteDocumentHistoryUseCase.java @@ -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. + *

+ * 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}): + *

    + *
  1. Alle {@code processing_attempt}-Einträge zum Fingerprint
  2. + *
  3. Den {@code document_record}-Stammsatz zum Fingerprint
  4. + *
+ * Die Operation ist idempotent: wenn kein Datensatz für den Fingerprint existiert, + * kehrt die Methode stillschweigend zurück. + *

+ * Hinweis: 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. + *

+ * 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()); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryDetailsUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryDetailsUseCase.java new file mode 100644 index 0000000..6266e6c --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryDetailsUseCase.java @@ -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. + *

+ * Kombiniert den Dokument-Stammsatz und alle historisierten Verarbeitungsversuche + * für einen bestimmten Fingerprint in einem einzigen Ergebnisobjekt. + *

+ * 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 loadDetails(DocumentFingerprint fingerprint) { + Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein"); + + Optional record = historyQueryPort.findRecordByFingerprint(fingerprint); + if (record.isEmpty()) { + return Optional.empty(); + } + + List 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 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"); + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryOverviewUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryOverviewUseCase.java new file mode 100644 index 0000000..189f416 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryOverviewUseCase.java @@ -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. + *

+ * 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. + *

+ * LIMIT-501-Technik: 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. + *

+ * 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 rows = historyQueryPort.loadOverview(query); + + if (rows.size() > MAX_DISPLAY_COUNT) { + // 501 Zeilen zurückgegeben: mehr als 500 Treffer vorhanden + List 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 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"); + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryResetDocumentStatusUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryResetDocumentStatusUseCase.java new file mode 100644 index 0000000..5c69204 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryResetDocumentStatusUseCase.java @@ -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. + *

+ * Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück, + * ohne die Versuchshistorie zu löschen: + *

    + *
  • {@code overall_status} → {@code READY_FOR_AI}
  • + *
  • {@code content_error_count} → {@code 0}
  • + *
  • {@code transient_error_count} → {@code 0}
  • + *
  • {@code last_failure_instant} → {@code null}
  • + *
+ * 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. + *

+ * 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. + *

+ * Abgrenzung: 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. + *

+ * 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()); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java index 34b4017..d9e4507 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java @@ -1416,6 +1416,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); diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java index f51cd90..50cacdf 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java @@ -1396,6 +1396,11 @@ class BatchRunProcessingUseCaseTest { public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { // No-op } + + @Override + public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { + // No-op + } }); } } @@ -1604,6 +1609,11 @@ class BatchRunProcessingUseCaseTest { public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { // No-op in tests } + + @Override + public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { + // No-op in tests + } }); } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultDeleteDocumentHistoryUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultDeleteDocumentHistoryUseCaseTest.java new file mode 100644 index 0000000..bb52e1f --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultDeleteDocumentHistoryUseCaseTest.java @@ -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}. + *

+ * 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 resetByFingerprintFingerprints = new ArrayList<>(); + final List 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); + } + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryDetailsUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryDetailsUseCaseTest.java new file mode 100644 index 0000000..a9003c7 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryDetailsUseCaseTest.java @@ -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}. + *

+ * 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 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 loadOverview(HistoryQuery query) { + return Collections.emptyList(); + } + + @Override + public Optional findRecordByFingerprint(DocumentFingerprint fp) { + return Optional.of(record); + } + + @Override + public List findAttemptsByFingerprint(DocumentFingerprint fp) { + return List.of(attempt); + } + }; + + DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port); + Optional 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 loadOverview(HistoryQuery query) { + return Collections.emptyList(); + } + + @Override + public Optional findRecordByFingerprint(DocumentFingerprint fp) { + return Optional.of(record); + } + + @Override + public List findAttemptsByFingerprint(DocumentFingerprint fp) { + return Collections.emptyList(); + } + }; + + DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port); + Optional 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 loadOverview(HistoryQuery query) { + return Collections.emptyList(); + } + + @Override + public Optional findRecordByFingerprint(DocumentFingerprint fp) { + throw new DocumentPersistenceException("Simulated DB error"); + } + + @Override + public List 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 loadOverview(HistoryQuery query) { + return Collections.emptyList(); + } + + @Override + public Optional findRecordByFingerprint(DocumentFingerprint fp) { + return Optional.empty(); + } + + @Override + public List 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); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryOverviewUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryOverviewUseCaseTest.java new file mode 100644 index 0000000..9d785dd --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryOverviewUseCaseTest.java @@ -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}. + *

+ * 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 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 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 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 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 loadOverview(HistoryQuery query) { + throw new DocumentPersistenceException("Simulated DB error"); + } + + @Override + public Optional findRecordByFingerprint(DocumentFingerprint fp) { + return Optional.empty(); + } + + @Override + public List 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 rows) { + return new HistoryQueryPort() { + @Override + public List loadOverview(HistoryQuery query) { + return new ArrayList<>(rows); + } + + @Override + public Optional findRecordByFingerprint(DocumentFingerprint fp) { + return Optional.empty(); + } + + @Override + public List findAttemptsByFingerprint(DocumentFingerprint fp) { + return Collections.emptyList(); + } + }; + } + + private static List buildRows(int count) { + List 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); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryResetDocumentStatusUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryResetDocumentStatusUseCaseTest.java new file mode 100644 index 0000000..7bf45d3 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultHistoryResetDocumentStatusUseCaseTest.java @@ -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}. + *

+ * 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 resetStatusForRetryFingerprints = new ArrayList<>(); + final List 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); + } + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCaseTest.java index 40341c2..c9e6e23 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileCopyUseCaseTest.java @@ -549,6 +549,7 @@ class DefaultManualFileCopyUseCaseTest { @Override public void createDocumentRecord(DocumentRecord record) { } @Override public void updateDocumentRecord(DocumentRecord record) { } @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } + @Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { } } private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations { @@ -562,5 +563,6 @@ class DefaultManualFileCopyUseCaseTest { @Override public void createDocumentRecord(DocumentRecord record) { } @Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); } @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } + @Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { } } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCaseTest.java index 5096dc7..06d9fef 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCaseTest.java @@ -620,6 +620,7 @@ class DefaultManualFileRenameUseCaseTest { @Override public void createDocumentRecord(DocumentRecord record) { } @Override public void updateDocumentRecord(DocumentRecord record) { } @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } + @Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { } } /** Zeichnet updateDocumentRecord-Aufrufe auf. */ @@ -634,5 +635,6 @@ class DefaultManualFileRenameUseCaseTest { @Override public void createDocumentRecord(DocumentRecord record) { } @Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); } @Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { } + @Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { } } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResetDocumentStatusUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResetDocumentStatusUseCaseTest.java index a523702..5101334 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResetDocumentStatusUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResetDocumentStatusUseCaseTest.java @@ -216,5 +216,10 @@ class DefaultResetDocumentStatusUseCaseTest { public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { recorded.add(fingerprint); } + + @Override + public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { + // No-op in tests + } } } diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index f49d9c5..9c3a3ca 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -87,7 +87,18 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatal import de.gecheckt.pdf.umbenenner.application.service.AiNamingService; import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator; import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator; +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.out.sqlite.SqliteHistoryQueryAdapter; +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.DefaultBatchRunProcessingUseCase; +import de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase; +import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase; +import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase; +import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultPromptEditorUseCase; @@ -808,6 +819,10 @@ public class BootstrapRunner { GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename; GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy; GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui; + GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui; + GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui; + GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui; + GuiDeleteDocumentHistoryPort deleteHistoryPort = this::deleteDocumentHistoryForGui; // Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start String applicationVersion = ApplicationVersionProvider.resolveVersion(); @@ -830,7 +845,11 @@ public class BootstrapRunner { manualCopyPort, historicalDocumentContextPort, applicationVersion, - noOpGuiPromptEditorPort()); + noOpGuiPromptEditorPort(), + historyOverviewPort, + historyDetailsPort, + historyResetPort, + deleteHistoryPort); } Path configPath = Paths.get(configPathOverride.get()); @@ -856,7 +875,11 @@ public class BootstrapRunner { manualCopyPort, historicalDocumentContextPort, applicationVersion, - noOpGuiPromptEditorPort()); + noOpGuiPromptEditorPort(), + historyOverviewPort, + historyDetailsPort, + historyResetPort, + deleteHistoryPort); } LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath()); @@ -868,7 +891,8 @@ public class BootstrapRunner { modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, miniRunLauncher, resetPort, manualRenamePort, manualCopyPort, - historicalDocumentContextPort, applicationVersion, promptEditorPort); + historicalDocumentContextPort, applicationVersion, promptEditorPort, + historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort); } catch (GuiConfigurationLoadException e) { LOG.error("GUI startup: configuration could not be loaded, starting without it: {}", e.getMessage(), e); @@ -890,7 +914,11 @@ public class BootstrapRunner { manualCopyPort, historicalDocumentContextPort, applicationVersion, - noOpGuiPromptEditorPort()); + noOpGuiPromptEditorPort(), + historyOverviewPort, + historyDetailsPort, + historyResetPort, + deleteHistoryPort); } } @@ -1421,6 +1449,133 @@ public class BootstrapRunner { } } + /** + * Lädt die gefilterte Dokumentenübersicht für den Historien-Tab. + *

+ * Verdrahtet {@link SqliteHistoryQueryAdapter} und {@link DefaultHistoryOverviewUseCase} + * frisch pro Aufruf, da keine persistente Verbindung über GUI-Interaktionen hinweg + * gehalten wird. + * + * @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein + * @param query Abfrageparameter; darf nicht {@code null} sein + * @return Ergebnis mit gefundenen Zeilen und hasMore-Flag; nie {@code null} + */ + DefaultHistoryOverviewUseCase.HistoryOverviewResult loadHistoryOverviewForGui( + Path configFilePath, + de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery query) { + Objects.requireNonNull(configFilePath, "configFilePath must not be null"); + Objects.requireNonNull(query, "query must not be null"); + try { + migrateConfigurationIfNeeded(configFilePath); + StartConfiguration config = loadAndValidateConfiguration(configFilePath); + initializeSchema(config); + String jdbcUrl = buildJdbcUrl(config); + HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl); + DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(historyQueryPort); + return useCase.loadOverview(query); + } catch (Exception e) { + LOG.error("Historienübersicht konnte nicht geladen werden: {}", e.getMessage(), e); + throw new DocumentPersistenceException( + "Historienübersicht konnte nicht geladen werden: " + e.getMessage(), e); + } + } + + /** + * Lädt Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint. + *

+ * Verdrahtet {@link SqliteHistoryQueryAdapter} und {@link DefaultHistoryDetailsUseCase} + * frisch pro Aufruf. + * + * @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein + * @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein + * @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde + */ + Optional loadHistoryDetailsForGui( + Path configFilePath, + DocumentFingerprint fingerprint) { + Objects.requireNonNull(configFilePath, "configFilePath must not be null"); + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + try { + migrateConfigurationIfNeeded(configFilePath); + StartConfiguration config = loadAndValidateConfiguration(configFilePath); + initializeSchema(config); + String jdbcUrl = buildJdbcUrl(config); + HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl); + DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(historyQueryPort); + return useCase.loadDetails(fingerprint); + } catch (Exception e) { + LOG.error("Historiendetails für {} konnten nicht geladen werden: {}", + fingerprint.sha256Hex(), e.getMessage(), e); + throw new DocumentPersistenceException( + "Historiendetails konnten nicht geladen werden: " + e.getMessage(), e); + } + } + + /** + * Führt den feldgenauen Status-Reset für das angegebene Dokument durch. + *

+ * Setzt {@code overall_status}, {@code content_error_count}, {@code transient_error_count} + * und {@code last_failure_instant} zurück. Die Versuchshistorie bleibt erhalten. + * + * @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein + * @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein + */ + void resetHistoryDocumentStatusForGui( + Path configFilePath, + DocumentFingerprint fingerprint) { + Objects.requireNonNull(configFilePath, "configFilePath must not be null"); + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + LOG.info("Historien-Status-Reset für Fingerprint: {}", fingerprint.sha256Hex()); + try { + migrateConfigurationIfNeeded(configFilePath); + StartConfiguration config = loadAndValidateConfiguration(configFilePath); + initializeSchema(config); + String jdbcUrl = buildJdbcUrl(config); + UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl); + DefaultHistoryResetDocumentStatusUseCase useCase = + new DefaultHistoryResetDocumentStatusUseCase(unitOfWorkPort); + useCase.resetStatus(fingerprint); + LOG.info("Historien-Status-Reset abgeschlossen für Fingerprint: {}", fingerprint.sha256Hex()); + } catch (Exception e) { + LOG.error("Historien-Status-Reset fehlgeschlagen für {}: {}", + fingerprint.sha256Hex(), e.getMessage(), e); + throw new DocumentPersistenceException( + "Status-Reset fehlgeschlagen: " + e.getMessage(), e); + } + } + + /** + * Löscht den Stammsatz und alle Verarbeitungsversuche für das angegebene Dokument. + *

+ * Die Löschung ist destruktiv und nicht rückgängig zu machen. + * + * @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein + * @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein + */ + void deleteDocumentHistoryForGui( + Path configFilePath, + DocumentFingerprint fingerprint) { + Objects.requireNonNull(configFilePath, "configFilePath must not be null"); + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + LOG.info("Historien-Löschen für Fingerprint: {}", fingerprint.sha256Hex()); + try { + migrateConfigurationIfNeeded(configFilePath); + StartConfiguration config = loadAndValidateConfiguration(configFilePath); + initializeSchema(config); + String jdbcUrl = buildJdbcUrl(config); + UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl); + DefaultDeleteDocumentHistoryUseCase useCase = + new DefaultDeleteDocumentHistoryUseCase(unitOfWorkPort); + useCase.deleteHistory(fingerprint); + LOG.info("Historien-Löschen abgeschlossen für Fingerprint: {}", fingerprint.sha256Hex()); + } catch (Exception e) { + LOG.error("Historien-Löschen fehlgeschlagen für {}: {}", + fingerprint.sha256Hex(), e.getMessage(), e); + throw new DocumentPersistenceException( + "Löschen fehlgeschlagen: " + e.getMessage(), e); + } + } + /** * Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is * recorded as a failure with the given error message.