#7: Historien-Tab mit Liste, Detail, Filter, Status-Reset und Eintrag-Loeschen

Implementiert den vollstaendigen Historien-Tab (Verlauf) als vierten Tab der GUI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 13:57:07 +02:00
parent 5d5dee0bbf
commit 46fc1d4fa4
31 changed files with 3095 additions and 17 deletions
@@ -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
@@ -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 */ };
}
}
@@ -0,0 +1,39 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase}.
* <p>
* Löscht den Dokument-Stammsatz und alle zugehörigen Verarbeitungsversuche
* vollständig und transaktional. Die Löschung ist destruktiv und nicht
* rückgängig zu machen.
* <p>
* Die GUI muss vor dem Aufruf dieses Ports einen Bestätigungsdialog mit
* explizitem Warnhinweis anzeigen.
* <p>
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
* bis die Löschung abgeschlossen ist.
*/
@FunctionalInterface
public interface GuiDeleteDocumentHistoryPort {
/**
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
* <p>
* Die Löschung erfolgt in der korrekten Reihenfolge innerhalb einer Transaktion:
* zuerst alle {@code processing_attempt}-Einträge, dann der {@code document_record}-Stammsatz.
* Ist kein Datensatz vorhanden, kehrt die Methode stillschweigend zurück.
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
* darf nicht {@code null} sein
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
void deleteHistory(Path configFilePath, DocumentFingerprint fingerprint);
}
@@ -0,0 +1,38 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase}.
* <p>
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Es ist eine modul-interne Brücke, über die Bootstrap die Detaildaten
* für einen ausgewählten Dokumenteintrag bereitstellt.
* <p>
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann.
* <p>
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
* bis das Ergebnis vollständig vorliegt.
*/
@FunctionalInterface
public interface GuiHistoryDetailsPort {
/**
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
* darf nicht {@code null} sein
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
Optional<HistoryDetailsResult> loadDetails(Path configFilePath, DocumentFingerprint fingerprint);
}
@@ -0,0 +1,43 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
/**
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase}.
* <p>
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Es ist eine modul-interne Brücke, über die Bootstrap die Dokumentenliste
* für den Historien-Tab bereitstellt, ohne dass der GUI-Adapter direkt auf
* Repository-Implementierungen zugreift.
* <p>
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann,
* ohne den Pfad global zu speichern.
* <p>
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
* bis das Ergebnis vollständig vorliegt.
*/
@FunctionalInterface
public interface GuiHistoryOverviewPort {
/**
* Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
* <p>
* Bei mehr als 500 Treffern enthält das Ergebnis genau 500 Zeilen und
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
* darf nicht {@code null} sein
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit;
* darf nicht {@code null} sein
* @return Ergebnisobjekt mit Trefferliste und {@code hasMore}-Flag; nie {@code null}
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
HistoryOverviewResult loadOverview(Path configFilePath, HistoryQuery query);
}
@@ -0,0 +1,47 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase}.
* <p>
* Führt einen feldgenauen Status-Reset durch: ausschließlich {@code overall_status},
* {@code content_error_count}, {@code transient_error_count} und
* {@code last_failure_instant} werden zurückgesetzt. Die Versuchshistorie bleibt
* vollständig erhalten. Nach dem Reset gilt das Dokument beim nächsten
* Verarbeitungslauf als verarbeitbar.
* <p>
* <strong>Abgrenzung zu {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort}:</strong>
* Der bestehende Reset-Port im {@code batchrun}-Paket löscht alle Persistenzdaten
* (Stammsatz und Versuchshistorie) vollständig. Dieser Port hier führt ausschließlich
* einen feldgenauen Update durch und lässt die Versuchshistorie unangetastet.
* <p>
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
* bis die Operation abgeschlossen ist.
*/
@FunctionalInterface
public interface GuiHistoryResetDocumentStatusPort {
/**
* Setzt den Status des Dokuments feldgenau zurück.
* <p>
* Folgende Felder werden aktualisiert:
* <ul>
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
* <li>{@code content_error_count} → {@code 0}</li>
* <li>{@code transient_error_count} → {@code 0}</li>
* <li>{@code last_failure_instant} → {@code null}</li>
* </ul>
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
* darf nicht {@code null} sein
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
void resetStatus(Path configFilePath, DocumentFingerprint fingerprint);
}
@@ -0,0 +1,794 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SplitPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
/**
* Dritter Haupt-Tab des JavaFX-Editorfensters: der Historien-Tab „Verlauf".
* <p>
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
*
* <h2>Layout</h2>
* <pre>
* ┌─────────────────────────────────────────────────────────────────┐
* │ [ Suchfeld ] [ Status ▾ ] [ Aktualisieren ] │
* ├────────────────────────┬────────────────────────────────────────┤
* │ Dokumentenliste (~55%) │ Detailbereich (~45%) │
* │ │ Dokument-Info │
* │ │ Versuche-Tabelle │
* │ │ KI-Begründung │
* ├────────────────────────┴────────────────────────────────────────┤
* │ [ Status zurücksetzen ] [ Eintrag löschen ] Statuszeile │
* └─────────────────────────────────────────────────────────────────┘
* </pre>
*
* <h2>Threading</h2>
* <p>Alle DB-Zugriffe laufen auf einem Hintergrund-Worker-Thread.
* UI-Updates erfolgen ausschließlich via {@code Platform.runLater()}.
* Destruktive Aktionen (Reset, Löschen) sind während eines aktiven
* Verarbeitungslaufs deaktiviert.
*/
public final class GuiHistoryTab {
private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class);
private static final String TAB_TITLE = "Verlauf";
private static final String EMPTY_DB_TEXT = "Noch keine Verarbeitungen vorhanden.";
private static final String TOO_MANY_RESULTS_TEXT =
"Weitere Einträge vorhanden Filter verwenden um die Trefferliste einzuschränken.";
private static final String DETAIL_PLACEHOLDER = "Dokument auswählen für Details";
private static final String NO_REASONING_TEXT = "Kein KI-Reasoning für diesen Versuch vorhanden.";
private static final String LOADING_TEXT = "Wird geladen …";
private static final String LAUF_AKTIV_HINWEIS = "Aktion während Verarbeitungslauf nicht möglich.";
private static final DateTimeFormatter TIMESTAMP_FMT =
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm").withZone(ZoneId.systemDefault());
// ---- Bridge-Ports ---------------------------------------------------
private final GuiHistoryOverviewPort overviewPort;
private final GuiHistoryDetailsPort detailsPort;
private final GuiHistoryResetDocumentStatusPort resetPort;
private final GuiDeleteDocumentHistoryPort deletePort;
private final BooleanSupplier runningCheck;
/** Liefert den Pfad zur aktuell geladenen Konfigurationsdatei, oder {@code null} wenn keine geladen. */
private final Supplier<Path> configPathSupplier;
// ---- JavaFX-Knoten --------------------------------------------------
private final Tab tab = new Tab(TAB_TITLE);
private final TextField searchField = new TextField();
private final ComboBox<String> statusFilterBox = new ComboBox<>();
private final Button refreshButton = new Button("Aktualisieren");
private final TableView<DocumentHistoryRow> overviewTable = new TableView<>();
private final ObservableList<DocumentHistoryRow> overviewItems = FXCollections.observableArrayList();
private final Label statusBarLabel = new Label();
private final Label moreThanMaxLabel = new Label();
// Detailbereich
private final GridPane detailGrid = new GridPane();
private final Label detailFingerprintLabel = new Label();
private final Label detailSourceFileLabel = new Label();
private final Label detailSourcePathLabel = new Label();
private final Label detailStatusLabel = new Label();
private final Label detailCreatedLabel = new Label();
private final Label detailUpdatedLabel = new Label();
private final TableView<ProcessingAttempt> attemptsTable = new TableView<>();
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
private final TextArea reasoningArea = new TextArea();
private final Button resetButton = new Button("Status zurücksetzen");
private final Button deleteButton = new Button("Eintrag löschen");
// ---- Zustand --------------------------------------------------------
private final ExecutorService workerPool;
/**
* Erzeugt den Historien-Tab.
*
* @param overviewPort Brücke zur Dokumentenübersicht; darf nicht {@code null} sein
* @param detailsPort Brücke zur Detailansicht; darf nicht {@code null} sein
* @param resetPort Brücke zum feldgenauen Status-Reset; darf nicht {@code null} sein
* @param deletePort Brücke zum vollständigen Löschen; darf nicht {@code null} sein
* @param runningCheck Liefert {@code true} wenn gerade ein Verarbeitungslauf aktiv ist;
* darf nicht {@code null} sein
* @param configPathSupplier Liefert den Pfad zur aktuell geladenen Konfigurationsdatei,
* oder {@code null} wenn keine geladen ist; darf nicht {@code null} sein
*/
public GuiHistoryTab(
GuiHistoryOverviewPort overviewPort,
GuiHistoryDetailsPort detailsPort,
GuiHistoryResetDocumentStatusPort resetPort,
GuiDeleteDocumentHistoryPort deletePort,
BooleanSupplier runningCheck,
Supplier<Path> configPathSupplier) {
this.overviewPort = Objects.requireNonNull(overviewPort, "overviewPort darf nicht null sein");
this.detailsPort = Objects.requireNonNull(detailsPort, "detailsPort darf nicht null sein");
this.resetPort = Objects.requireNonNull(resetPort, "resetPort darf nicht null sein");
this.deletePort = Objects.requireNonNull(deletePort, "deletePort darf nicht null sein");
this.runningCheck = Objects.requireNonNull(runningCheck, "runningCheck darf nicht null sein");
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier darf nicht null sein");
this.workerPool = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "HistoryTabWorker");
t.setDaemon(true);
return t;
});
buildUi();
wireEvents();
tab.setClosable(false);
}
/**
* Liefert den JavaFX-{@link Tab}, der in die TabPane eingefügt werden kann.
*
* @return der Tab; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Lädt die Dokumentenübersicht neu muss auf dem JavaFX Application Thread aufgerufen werden.
* Wird vom Tab-Wechsel-Listener ausgelöst.
*/
public void refresh() {
loadOverview();
}
// =========================================================================
// UI-Aufbau
// =========================================================================
private void buildUi() {
// --- Toolbar ---
searchField.setPromptText("Suche nach Dateiname …");
searchField.setPrefWidth(300);
Tooltip.install(searchField, new Tooltip(
"Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal)."));
statusFilterBox.getItems().add("Alle Status");
for (ProcessingStatus s : ProcessingStatus.values()) {
statusFilterBox.getItems().add(s.name());
}
statusFilterBox.getSelectionModel().selectFirst();
Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen."));
refreshButton.setTooltip(new Tooltip("Dokumentenliste neu aus der Datenbank laden."));
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
HBox toolbar = new HBox(8, searchField, statusFilterBox, spacer, refreshButton);
toolbar.setAlignment(Pos.CENTER_LEFT);
toolbar.setPadding(new Insets(6, 8, 6, 8));
// --- Dokumentenliste (links) ---
buildOverviewTable();
moreThanMaxLabel.setStyle("-fx-text-fill: #d98200; -fx-font-style: italic;");
moreThanMaxLabel.setVisible(false);
moreThanMaxLabel.setManaged(false);
VBox leftPane = new VBox(4, overviewTable, moreThanMaxLabel);
VBox.setVgrow(overviewTable, Priority.ALWAYS);
leftPane.setPadding(new Insets(0, 4, 0, 0));
// --- Detailbereich (rechts) ---
VBox rightPane = buildDetailPane();
// --- SplitPane ---
SplitPane splitPane = new SplitPane(leftPane, rightPane);
splitPane.setDividerPositions(0.55);
// --- Aktionsleiste unten ---
resetButton.setTooltip(new Tooltip(
"Setzt Status, Fehlerzähler und letzten Fehlerzeitpunkt zurück. "
+ "Versuche bleiben erhalten. Das Dokument wird beim nächsten Lauf erneut verarbeitet."));
deleteButton.setTooltip(new Tooltip(
"Löscht den Eintrag und alle Versuche vollständig. "
+ "Diese Aktion ist nicht rückgängig zu machen."));
resetButton.setDisable(true);
deleteButton.setDisable(true);
statusBarLabel.setStyle("-fx-text-fill: #555555; -fx-font-style: italic;");
HBox actionBar = new HBox(8, resetButton, deleteButton, spacerNew(), statusBarLabel);
actionBar.setAlignment(Pos.CENTER_LEFT);
actionBar.setPadding(new Insets(6, 8, 6, 8));
// --- Gesamtlayout ---
BorderPane content = new BorderPane();
content.setTop(toolbar);
content.setCenter(splitPane);
content.setBottom(actionBar);
BorderPane.setMargin(toolbar, Insets.EMPTY);
tab.setContent(content);
}
private void buildOverviewTable() {
overviewTable.setItems(overviewItems);
overviewTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
// Status-Icon-Spalte
TableColumn<DocumentHistoryRow, String> statusCol = new TableColumn<>("Status");
statusCol.setCellValueFactory(cell ->
new SimpleStringProperty(statusIcon(cell.getValue().overallStatus())));
statusCol.setCellFactory(col -> new TableCell<>() {
@Override
protected void updateItem(String icon, boolean empty) {
super.updateItem(icon, empty);
if (empty || icon == null) {
setText(null);
setTooltip(null);
} else {
setText(icon);
DocumentHistoryRow row = getTableView().getItems().get(getIndex());
setStyle("-fx-text-fill: " + statusColor(row.overallStatus()) + "; -fx-font-weight: bold;");
setTooltip(new Tooltip(statusTooltip(row.overallStatus())));
}
}
});
statusCol.setPrefWidth(60);
statusCol.setMaxWidth(70);
// Quelldateiname
TableColumn<DocumentHistoryRow, String> sourceCol = new TableColumn<>("Quelldatei");
sourceCol.setCellValueFactory(cell ->
new SimpleStringProperty(cell.getValue().sourceFileName()));
sourceCol.setCellFactory(col -> ellipsisCell());
// Zieldateiname
TableColumn<DocumentHistoryRow, String> targetCol = new TableColumn<>("Zieldatei");
targetCol.setCellValueFactory(cell ->
new SimpleStringProperty(
cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : ""));
targetCol.setCellFactory(col -> ellipsisCell());
// Letzter Versuch
TableColumn<DocumentHistoryRow, String> updatedCol = new TableColumn<>("Letzter Versuch");
updatedCol.setCellValueFactory(cell ->
new SimpleStringProperty(formatInstant(cell.getValue().updatedAt())));
updatedCol.setPrefWidth(140);
updatedCol.setMaxWidth(160);
// Anzahl Versuche
TableColumn<DocumentHistoryRow, String> countCol = new TableColumn<>("Versuche");
countCol.setCellValueFactory(cell ->
new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount())));
countCol.setPrefWidth(70);
countCol.setMaxWidth(80);
overviewTable.getColumns().setAll(statusCol, sourceCol, targetCol, updatedCol, countCol);
}
private VBox buildDetailPane() {
// Dokument-Info
detailGrid.setHgap(8);
detailGrid.setVgap(4);
detailGrid.setPadding(new Insets(8));
addDetailRow(0, "Fingerprint:", detailFingerprintLabel);
addDetailRow(1, "Quelldatei:", detailSourceFileLabel);
addDetailRow(2, "Quellpfad:", detailSourcePathLabel);
addDetailRow(3, "Status:", detailStatusLabel);
addDetailRow(4, "Erstellt:", detailCreatedLabel);
addDetailRow(5, "Aktualisiert:", detailUpdatedLabel);
Label detailTitle = new Label("Dokument-Details");
detailTitle.setStyle("-fx-font-weight: bold;");
// Versuche-Tabelle
buildAttemptsTable();
Label attemptsTitle = new Label("Verarbeitungsversuche");
attemptsTitle.setStyle("-fx-font-weight: bold;");
// KI-Begründung
reasoningArea.setEditable(false);
reasoningArea.setWrapText(true);
reasoningArea.setPrefRowCount(4);
reasoningArea.setText(DETAIL_PLACEHOLDER);
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
reasoningTitle.setStyle("-fx-font-weight: bold;");
VBox rightPane = new VBox(8,
detailTitle, detailGrid,
attemptsTitle, attemptsTable,
reasoningTitle, reasoningArea);
rightPane.setPadding(new Insets(4, 8, 4, 4));
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
ScrollPane scroll = new ScrollPane(rightPane);
scroll.setFitToWidth(true);
scroll.setFitToHeight(true);
VBox wrapper = new VBox(scroll);
VBox.setVgrow(scroll, Priority.ALWAYS);
return wrapper;
}
private void buildAttemptsTable() {
attemptsTable.setItems(attemptsItems);
attemptsTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
attemptsTable.setPlaceholder(new Label("Keine Versuche vorhanden."));
attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
attemptsTable.setPrefHeight(150);
TableColumn<ProcessingAttempt, String> numCol = new TableColumn<>("#");
numCol.setCellValueFactory(c ->
new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber())));
numCol.setPrefWidth(40);
numCol.setMaxWidth(50);
TableColumn<ProcessingAttempt, String> dateCol = new TableColumn<>("Datum");
dateCol.setCellValueFactory(c ->
new SimpleStringProperty(formatInstant(c.getValue().endedAt())));
dateCol.setPrefWidth(130);
dateCol.setMaxWidth(150);
TableColumn<ProcessingAttempt, String> statusCol = new TableColumn<>("Status");
statusCol.setCellValueFactory(c ->
new SimpleStringProperty(
statusIcon(c.getValue().status()) + " " + c.getValue().status().name()));
statusCol.setPrefWidth(140);
TableColumn<ProcessingAttempt, String> providerCol = new TableColumn<>("Provider");
providerCol.setCellValueFactory(c ->
new SimpleStringProperty(
c.getValue().aiProvider() != null ? c.getValue().aiProvider() : ""));
providerCol.setPrefWidth(90);
TableColumn<ProcessingAttempt, String> modelCol = new TableColumn<>("Modell");
modelCol.setCellValueFactory(c ->
new SimpleStringProperty(
c.getValue().modelName() != null ? c.getValue().modelName() : ""));
modelCol.setCellFactory(col -> ellipsisCell());
TableColumn<ProcessingAttempt, String> fileNameCol = new TableColumn<>("Vorgeschlagener Name");
fileNameCol.setCellValueFactory(c ->
new SimpleStringProperty(
c.getValue().finalTargetFileName() != null
? c.getValue().finalTargetFileName() : ""));
fileNameCol.setCellFactory(col -> ellipsisCell());
attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol, fileNameCol);
}
// =========================================================================
// Event-Verdrahtung
// =========================================================================
private void wireEvents() {
refreshButton.setOnAction(e -> loadOverview());
// Debounce-artige Aktualisierung bei Texteingabe: direkte Suche bei Enter,
// sonst über Fokus-Verlust oder expliziten Aktualisieren-Button
searchField.setOnAction(e -> loadOverview());
statusFilterBox.setOnAction(e -> loadOverview());
// Detailbereich bei Zeilenselektion
overviewTable.getSelectionModel().selectedItemProperty().addListener(
(obs, old, selected) -> {
if (selected == null) {
clearDetailPane();
resetButton.setDisable(true);
deleteButton.setDisable(true);
} else {
resetButton.setDisable(runningCheck.getAsBoolean());
deleteButton.setDisable(runningCheck.getAsBoolean());
loadDetails(selected.fingerprint());
}
});
resetButton.setOnAction(e -> handleResetAction());
deleteButton.setOnAction(e -> handleDeleteAction());
// Tab soll beim ersten Betreten automatisch laden
tab.selectedProperty().addListener((obs, oldVal, selected) -> {
if (Boolean.TRUE.equals(selected)) {
loadOverview();
}
});
}
// =========================================================================
// Daten laden (Worker-Thread)
// =========================================================================
private void loadOverview() {
statusBarLabel.setText(LOADING_TEXT);
overviewItems.clear();
moreThanMaxLabel.setVisible(false);
moreThanMaxLabel.setManaged(false);
Path configPath = configPathSupplier.get();
if (configPath == null) {
statusBarLabel.setText("Keine Konfiguration geladen bitte zuerst eine Konfigurationsdatei öffnen.");
overviewTable.setPlaceholder(new Label("Keine Konfiguration geladen."));
return;
}
String searchText = searchField.getText();
String selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem();
String statusFilter = (selectedStatus == null || "Alle Status".equals(selectedStatus))
? null : selectedStatus;
HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT);
workerPool.submit(() -> {
try {
HistoryOverviewResult result = overviewPort.loadOverview(configPath, query);
Platform.runLater(() -> {
overviewItems.setAll(result.rows());
if (result.hasMore()) {
moreThanMaxLabel.setText(TOO_MANY_RESULTS_TEXT);
moreThanMaxLabel.setVisible(true);
moreThanMaxLabel.setManaged(true);
} else {
moreThanMaxLabel.setVisible(false);
moreThanMaxLabel.setManaged(false);
}
if (result.rows().isEmpty()) {
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
statusBarLabel.setText("Keine Einträge gefunden.");
} else {
statusBarLabel.setText(result.rows().size() + " Einträge geladen.");
}
});
} catch (Exception ex) {
LOG.error("Fehler beim Laden der Historienübersicht: {}", ex.getMessage(), ex);
Platform.runLater(() ->
statusBarLabel.setText("Fehler beim Laden: " + ex.getMessage()));
}
});
}
private void loadDetails(DocumentFingerprint fingerprint) {
reasoningArea.setText(LOADING_TEXT);
attemptsItems.clear();
clearDetailFields();
Path configPath = configPathSupplier.get();
if (configPath == null) {
reasoningArea.setText(DETAIL_PLACEHOLDER);
return;
}
workerPool.submit(() -> {
try {
Optional<HistoryDetailsResult> result = detailsPort.loadDetails(configPath, fingerprint);
Platform.runLater(() -> {
if (result.isEmpty()) {
clearDetailPane();
statusBarLabel.setText("Eintrag nicht mehr vorhanden.");
} else {
populateDetailPane(result.get());
}
});
} catch (Exception ex) {
LOG.error("Fehler beim Laden der Dokumentdetails für {}: {}",
fingerprint.sha256Hex(), ex.getMessage(), ex);
Platform.runLater(() -> {
reasoningArea.setText("Fehler beim Laden der Details: " + ex.getMessage());
statusBarLabel.setText("Fehler beim Laden der Details.");
});
}
});
}
// =========================================================================
// Aktionen
// =========================================================================
private void handleResetAction() {
if (runningCheck.getAsBoolean()) {
showInfo(LAUF_AKTIV_HINWEIS);
return;
}
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
if (selected == null) return;
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
confirm.setTitle("Status zurücksetzen");
confirm.setHeaderText("Status zurücksetzen?");
confirm.setContentText(
"Setzt den Status des Dokuments auf READY_FOR_AI zurück.\n"
+ "Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n"
+ "Die Versuchshistorie bleibt vollständig erhalten.\n\n"
+ "Das Dokument wird beim nächsten Verarbeitungslauf erneut verarbeitet.\n\n"
+ "Quelldatei: " + selected.sourceFileName());
Optional<ButtonType> choice = confirm.showAndWait();
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
DocumentFingerprint fp = selected.fingerprint();
Path configPath = configPathSupplier.get();
if (configPath == null) {
showInfo("Keine Konfiguration geladen.");
return;
}
resetButton.setDisable(true);
deleteButton.setDisable(true);
statusBarLabel.setText("Status wird zurückgesetzt …");
workerPool.submit(() -> {
try {
resetPort.resetStatus(configPath, fp);
LOG.info("Status-Reset durchgeführt für Fingerprint: {}", fp.sha256Hex());
Platform.runLater(() -> {
statusBarLabel.setText("Status erfolgreich zurückgesetzt.");
loadOverview();
});
} catch (Exception ex) {
LOG.error("Status-Reset fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
Platform.runLater(() -> {
statusBarLabel.setText("Fehler beim Status-Reset: " + ex.getMessage());
resetButton.setDisable(false);
deleteButton.setDisable(false);
});
}
});
}
private void handleDeleteAction() {
if (runningCheck.getAsBoolean()) {
showInfo(LAUF_AKTIV_HINWEIS);
return;
}
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
if (selected == null) return;
Alert confirm = new Alert(Alert.AlertType.WARNING);
confirm.setTitle("Eintrag löschen");
confirm.setHeaderText("Eintrag vollständig löschen?");
confirm.setContentText(
"Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n"
+ "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
+ "Quelldatei: " + selected.sourceFileName());
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
Optional<ButtonType> choice = confirm.showAndWait();
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
DocumentFingerprint fp = selected.fingerprint();
Path configPath = configPathSupplier.get();
if (configPath == null) {
showInfo("Keine Konfiguration geladen.");
return;
}
resetButton.setDisable(true);
deleteButton.setDisable(true);
statusBarLabel.setText("Eintrag wird gelöscht …");
workerPool.submit(() -> {
try {
deletePort.deleteHistory(configPath, fp);
LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", fp.sha256Hex());
Platform.runLater(() -> {
statusBarLabel.setText("Eintrag erfolgreich gelöscht.");
clearDetailPane();
loadOverview();
});
} catch (Exception ex) {
LOG.error("Löschen fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
Platform.runLater(() -> {
statusBarLabel.setText("Fehler beim Löschen: " + ex.getMessage());
resetButton.setDisable(false);
deleteButton.setDisable(false);
});
}
});
}
// =========================================================================
// Detail-Bereich befüllen / leeren
// =========================================================================
private void populateDetailPane(HistoryDetailsResult result) {
DocumentRecord record = result.record();
String fpFull = record.fingerprint().sha256Hex();
detailFingerprintLabel.setText(fpFull.substring(0, Math.min(12, fpFull.length())) + "");
detailFingerprintLabel.setTooltip(new Tooltip(fpFull));
detailSourceFileLabel.setText(record.lastKnownSourceFileName());
detailSourcePathLabel.setText(record.lastKnownSourceLocator().value());
detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value()));
String icon = statusIcon(record.overallStatus());
detailStatusLabel.setText(icon + " " + record.overallStatus().name());
detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";");
detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus())));
detailCreatedLabel.setText(formatInstant(record.createdAt()));
detailUpdatedLabel.setText(formatInstant(record.updatedAt()));
attemptsItems.setAll(result.attempts());
// Neuesten Versuch selektieren und Begründung anzeigen
if (!result.attempts().isEmpty()) {
ProcessingAttempt last = result.attempts().get(result.attempts().size() - 1);
attemptsTable.getSelectionModel().select(last);
showReasoning(last);
} else {
reasoningArea.setText(NO_REASONING_TEXT);
}
// KI-Begründung bei Versuchs-Selektion aktualisieren
attemptsTable.getSelectionModel().selectedItemProperty().addListener(
(obs, old, attempt) -> {
if (attempt != null) {
showReasoning(attempt);
}
});
}
private void showReasoning(ProcessingAttempt attempt) {
String reasoning = attempt.aiReasoning();
reasoningArea.setText(reasoning != null && !reasoning.isBlank()
? reasoning : NO_REASONING_TEXT);
}
private void clearDetailPane() {
clearDetailFields();
attemptsItems.clear();
reasoningArea.setText(DETAIL_PLACEHOLDER);
}
private void clearDetailFields() {
detailFingerprintLabel.setText("");
detailFingerprintLabel.setTooltip(null);
detailSourceFileLabel.setText("");
detailSourcePathLabel.setText("");
detailSourcePathLabel.setTooltip(null);
detailStatusLabel.setText("");
detailStatusLabel.setStyle("");
detailStatusLabel.setTooltip(null);
detailCreatedLabel.setText("");
detailUpdatedLabel.setText("");
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
private void addDetailRow(int row, String labelText, Label valueLabel) {
Label label = new Label(labelText);
label.setStyle("-fx-font-weight: bold;");
valueLabel.setMaxWidth(Double.MAX_VALUE);
GridPane.setHgrow(valueLabel, Priority.ALWAYS);
detailGrid.add(label, 0, row);
detailGrid.add(valueLabel, 1, row);
}
private String formatInstant(Instant instant) {
if (instant == null) return "";
return TIMESTAMP_FMT.format(instant);
}
private static String statusIcon(ProcessingStatus status) {
if (status == null) return "?";
return switch (status) {
case SUCCESS -> "";
case FAILED_RETRYABLE -> "";
case FAILED_FINAL -> "×";
case SKIPPED_ALREADY_PROCESSED -> "";
case SKIPPED_FINAL_FAILURE -> "";
case READY_FOR_AI -> "";
case PROPOSAL_READY -> "";
case PROCESSING -> "";
};
}
private static String statusColor(ProcessingStatus status) {
if (status == null) return "#000000";
return switch (status) {
case SUCCESS -> "#2e7d32";
case FAILED_RETRYABLE -> "#d98200";
case FAILED_FINAL -> "#c62828";
case SKIPPED_ALREADY_PROCESSED -> "#757575";
case SKIPPED_FINAL_FAILURE -> "#424242";
case READY_FOR_AI -> "#1565c0";
case PROPOSAL_READY -> "#0288d1";
case PROCESSING -> "#9e9e9e";
};
}
private static String statusTooltip(ProcessingStatus status) {
if (status == null) return "";
return switch (status) {
case SUCCESS -> "Erfolgreich verarbeitet und umbenannt.";
case FAILED_RETRYABLE -> "Temporärer Fehler wird beim nächsten Lauf automatisch erneut versucht.";
case FAILED_FINAL -> "Dauerhaft nicht verarbeitbar z. B. kein Textinhalt (Foto-PDF), "
+ "Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch.";
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
case SKIPPED_FINAL_FAILURE -> "Endgültig übersprungen nach wiederholten Fehlern.";
case READY_FOR_AI -> "Wartet auf Verarbeitung.";
case PROPOSAL_READY -> "KI-Vorschlag liegt vor, wartet auf Bestätigung.";
case PROCESSING -> "Wird gerade verarbeitet.";
};
}
private static <T> TableCell<T, String> ellipsisCell() {
return new TableCell<>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setTooltip(null);
} else {
setText(item);
setTooltip(new Tooltip(item));
}
}
};
}
private static Region spacerNew() {
Region r = new Region();
HBox.setHgrow(r, Priority.ALWAYS);
return r;
}
private void showInfo(String message) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle("Hinweis");
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
}
@@ -0,0 +1,15 @@
/**
* GUI-Adapter für den Historien-Tab.
* <p>
* Enthält die Bridge-Interfaces {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort},
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort} und
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort}
* sowie die JavaFX-Komponente {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab}.
* <p>
* Die Bridge-Interfaces werden von Bootstrap implementiert und über
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext} in den GUI-Adapter injiziert.
* Die GUI-Komponenten kennen ausschließlich diese Interfaces
* niemals direkt Repository- oder Use-Case-Implementierungen.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
@@ -244,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()),
@@ -0,0 +1,205 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import javafx.application.Platform;
import javafx.scene.control.Tab;
/**
* Monocle-basierte Headless-Smoke-Tests für {@link GuiHistoryTab}.
* <p>
* Geprüfte Szenarien:
* <ul>
* <li>Tab wird mit Titel „Verlauf" erstellt.</li>
* <li>Tab ist nicht schließbar.</li>
* <li>Ohne geladene Konfiguration bleibt die Übersicht leer (null-configPath).</li>
* <li>Mit leerem Übersichts-Port bleibt die Tabelle leer.</li>
* </ul>
*/
class GuiHistoryTabSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
latch.countDown();
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform muss innerhalb des Timeouts starten");
} catch (IllegalStateException alreadyStarted) {
CountDownLatch verifyLatch = new CountDownLatch(1);
Platform.runLater(() -> {
PLATFORM_STARTED.set(true);
verifyLatch.countDown();
});
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
}
}
@AfterAll
static void tearDownJavaFxPlatform() {
// Gemeinsame Platform kein Platform.exit().
}
// =========================================================================
// Stubs
// =========================================================================
private static GuiHistoryOverviewPort emptyOverviewPort() {
return (configFilePath, query) ->
new HistoryOverviewResult(List.of(), false);
}
private static GuiHistoryDetailsPort emptyDetailsPort() {
return (configFilePath, fingerprint) -> Optional.empty();
}
private static GuiHistoryResetDocumentStatusPort noOpResetPort() {
return (configFilePath, fingerprint) -> { /* no-op */ };
}
private static GuiDeleteDocumentHistoryPort noOpDeletePort() {
return (configFilePath, fingerprint) -> { /* no-op */ };
}
private static GuiHistoryTab buildTab(Path configPath) {
return new GuiHistoryTab(
emptyOverviewPort(),
emptyDetailsPort(),
noOpResetPort(),
noOpDeletePort(),
() -> false,
() -> configPath);
}
// =========================================================================
// Tests
// =========================================================================
@Test
void tab_shouldHaveTitleVerlauf() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<Tab> tabRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
GuiHistoryTab historyTab = buildTab(null);
tabRef.set(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
assertEquals("Verlauf", tabRef.get().getText(), "Tab-Titel muss 'Verlauf' sein");
}
@Test
void tab_shouldNotBeClosable() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean closableRef = new AtomicBoolean(true);
Platform.runLater(() -> {
try {
GuiHistoryTab historyTab = buildTab(null);
closableRef.set(historyTab.tab().isClosable());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(closableRef.get(), "Tab darf nicht schließbar sein");
}
@Test
void construction_withNullConfigPath_doesNotThrow() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
// Konstruktion mit null-configPath-Supplier muss möglich sein
GuiHistoryTab historyTab = buildTab(null);
assertNotNull(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
}
@Test
void construction_withConfigPath_doesNotThrow() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
Path dummyPath = Paths.get("config/application.properties");
GuiHistoryTab historyTab = buildTab(dummyPath);
assertNotNull(historyTab.tab());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
}
}
@@ -0,0 +1,409 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
* SQLite-Implementierung von {@link HistoryQueryPort}.
* <p>
* Kapselt alle lesenden Datenbankoperationen für den Historien-Tab.
* Sämtliche JDBC-Details sind strikt in dieser Klasse eingeschlossen;
* keine JDBC-Typen erscheinen im Port-Interface oder in Domänen-/Application-Typen.
* <p>
* <strong>Suche:</strong> Freitextsuche ist case-insensitiv (via {@code LOWER()}).
* Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem
* SQL-LIKE-Aufruf mit {@code \} escaped.
* <p>
* <strong>Sortierung:</strong> Standard absteigend nach {@code updated_at},
* Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar).
* <p>
* <strong>Limit:</strong> Wird direkt als SQL-{@code LIMIT} angewendet.
* Ein Limit von 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr
* als 500 Treffer vorhanden sind.
*/
public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
private static final Logger logger = LogManager.getLogger(SqliteHistoryQueryAdapter.class);
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
private final String jdbcUrl;
/**
* Erzeugt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
*
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
* @throws NullPointerException wenn {@code jdbcUrl} null ist
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
*/
public SqliteHistoryQueryAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
}
this.jdbcUrl = jdbcUrl;
}
/**
* {@inheritDoc}
* <p>
* Die SQL-Abfrage aggregiert die Versuchsanzahl per {@code COUNT}-Subquery.
* Freitextsuche und Status-Filter werden als optionale WHERE-Klauseln ergänzt.
*
* @param query Abfrageparameter; darf nicht {@code null} sein
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
Objects.requireNonNull(query, "query darf nicht null sein");
StringBuilder sql = new StringBuilder("""
SELECT
dr.fingerprint,
dr.overall_status,
dr.last_known_source_file_name,
dr.last_target_file_name,
dr.last_known_source_locator,
dr.updated_at,
(SELECT COUNT(*) FROM processing_attempt pa WHERE pa.fingerprint = dr.fingerprint) AS attempt_count
FROM document_record dr
WHERE 1=1
""");
List<Object> params = new ArrayList<>();
// Freitextsuche: case-insensitiv über Quelldateiname und Zieldateiname
String searchText = query.searchText();
if (searchText != null && !searchText.isBlank()) {
String escaped = escapeSqlLike(searchText.strip().toLowerCase());
sql.append(" AND (LOWER(dr.last_known_source_file_name) LIKE ? ESCAPE '\\' "
+ "OR LOWER(dr.last_target_file_name) LIKE ? ESCAPE '\\')");
String pattern = "%" + escaped + "%";
params.add(pattern);
params.add(pattern);
}
// Status-Filter
String statusFilter = query.statusFilter();
if (statusFilter != null && !statusFilter.isBlank()) {
sql.append(" AND dr.overall_status = ?");
params.add(statusFilter.strip());
}
sql.append(" ORDER BY dr.updated_at DESC, dr.fingerprint ASC");
sql.append(" LIMIT ?");
params.add(query.limit());
try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement();
PreparedStatement stmt = connection.prepareStatement(sql.toString())) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
for (int i = 0; i < params.size(); i++) {
stmt.setObject(i + 1, params.get(i));
}
try (ResultSet rs = stmt.executeQuery()) {
List<DocumentHistoryRow> rows = new ArrayList<>();
while (rs.next()) {
rows.add(mapToDocumentHistoryRow(rs));
}
logger.debug("Historien-Übersicht geladen: {} Zeilen (Limit {})", rows.size(), query.limit());
return List.copyOf(rows);
}
} catch (SQLException e) {
String message = "Historien-Übersicht konnte nicht geladen werden: " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* {@inheritDoc}
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
String sql = """
SELECT
last_known_source_locator,
last_known_source_file_name,
overall_status,
content_error_count,
transient_error_count,
last_failure_instant,
last_success_instant,
created_at,
updated_at,
last_target_path,
last_target_file_name
FROM document_record
WHERE fingerprint = ?
""";
try (Connection connection = getConnection();
PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapToDocumentRecord(rs, fingerprint));
}
return Optional.empty();
}
} catch (SQLException e) {
String message = "Dokument-Stammsatz konnte nicht geladen werden für Fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* {@inheritDoc}
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return unveränderliche Liste der Versuche aufsteigend nach {@code attempt_number};
* nie {@code null}; kann leer sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
String sql = """
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
WHERE fingerprint = ?
ORDER BY attempt_number ASC
""";
try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement();
PreparedStatement stmt = connection.prepareStatement(sql)) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
stmt.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = stmt.executeQuery()) {
List<ProcessingAttempt> attempts = new ArrayList<>();
while (rs.next()) {
attempts.add(mapToProcessingAttempt(rs));
}
return List.copyOf(attempts);
}
} catch (SQLException e) {
String message = "Verarbeitungsversuche konnten nicht geladen werden für Fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
// -------------------------------------------------------------------------
// Mapping-Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Bildet eine ResultSet-Zeile auf eine {@link DocumentHistoryRow} ab.
*
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
* @return die gemappte Zeile; nie {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private DocumentHistoryRow mapToDocumentHistoryRow(ResultSet rs) throws SQLException {
String fpHex = rs.getString("fingerprint");
String statusStr = rs.getString("overall_status");
String sourceFileName = rs.getString("last_known_source_file_name");
String targetFileName = rs.getString("last_target_file_name"); // nullable
String sourcePath = rs.getString("last_known_source_locator");
String updatedAtStr = rs.getString("updated_at");
long attemptCount = rs.getLong("attempt_count");
return new DocumentHistoryRow(
new DocumentFingerprint(fpHex),
ProcessingStatus.valueOf(statusStr),
sourceFileName,
targetFileName,
sourcePath,
stringToInstant(updatedAtStr),
attemptCount);
}
/**
* Bildet eine ResultSet-Zeile auf einen {@link DocumentRecord} ab.
*
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
* @param fingerprint der Fingerprint, der bereits bekannt ist
* @return der gemappte Stammsatz; nie {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private DocumentRecord mapToDocumentRecord(ResultSet rs, DocumentFingerprint fingerprint) throws SQLException {
return new DocumentRecord(
fingerprint,
new SourceDocumentLocator(rs.getString("last_known_source_locator")),
rs.getString("last_known_source_file_name"),
ProcessingStatus.valueOf(rs.getString("overall_status")),
new FailureCounters(
rs.getInt("content_error_count"),
rs.getInt("transient_error_count")),
stringToInstant(rs.getString("last_failure_instant")),
stringToInstant(rs.getString("last_success_instant")),
stringToInstant(rs.getString("created_at")),
stringToInstant(rs.getString("updated_at")),
rs.getString("last_target_path"),
rs.getString("last_target_file_name"));
}
/**
* Bildet eine ResultSet-Zeile auf einen {@link ProcessingAttempt} ab.
*
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
* @return der gemappte Versuch; nie {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private ProcessingAttempt mapToProcessingAttempt(ResultSet rs) throws SQLException {
String resolvedDateStr = rs.getString("resolved_date");
LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;
String dateSourceStr = rs.getString("date_source");
DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;
int processedPageCountRaw = rs.getInt("processed_page_count");
Integer processedPageCount = rs.wasNull() ? null : processedPageCountRaw;
int sentCharacterCountRaw = rs.getInt("sent_character_count");
Integer sentCharacterCount = rs.wasNull() ? null : sentCharacterCountRaw;
return new ProcessingAttempt(
new DocumentFingerprint(rs.getString("fingerprint")),
new RunId(rs.getString("run_id")),
rs.getInt("attempt_number"),
stringToInstant(rs.getString("started_at")),
stringToInstant(rs.getString("ended_at")),
ProcessingStatus.valueOf(rs.getString("status")),
rs.getString("failure_class"),
rs.getString("failure_message"),
rs.getBoolean("retryable"),
rs.getString("ai_provider"),
rs.getString("model_name"),
rs.getString("prompt_identifier"),
processedPageCount,
sentCharacterCount,
rs.getString("ai_raw_response"),
rs.getString("ai_reasoning"),
resolvedDate,
dateSource,
rs.getString("validated_title"),
rs.getString("final_target_file_name"));
}
// -------------------------------------------------------------------------
// SQL-LIKE Escaping
// -------------------------------------------------------------------------
/**
* Escaped Sonderzeichen {@code %} und {@code _} in einer LIKE-Eingabe mit {@code \}.
* <p>
* Der Escape-Charakter {@code \} muss in der SQL-Abfrage als
* {@code ESCAPE '\'} angegeben werden.
*
* @param input die rohe Benutzereingabe; darf nicht {@code null} sein
* @return der escaped String; nie {@code null}
*/
private static String escapeSqlLike(String input) {
return input
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
}
// -------------------------------------------------------------------------
// JDBC-Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Öffnet eine neue Datenbankverbindung zur konfigurierten SQLite-Datei.
* <p>
* Kann in Unterklassen überschrieben werden, um eine gemeinsam genutzte
* Transaktions-Verbindung bereitzustellen.
*
* @return eine neue Datenbankverbindung
* @throws SQLException wenn die Verbindung nicht hergestellt werden kann
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
}
/**
* Parst einen Instant aus einer String-Darstellung.
* <p>
* Unterstützt ISO-8601 (modern) und das Legacy-Format {@code yyyy-MM-dd HH:mm:ss} (UTC).
*
* @param stringValue die String-Darstellung; kann {@code null} sein
* @return das geparste Instant, oder {@code null} wenn die Eingabe leer oder nicht parsbar ist
*/
private Instant stringToInstant(String stringValue) {
if (stringValue == null || stringValue.isBlank()) {
return null;
}
try {
return Instant.parse(stringValue);
} catch (Exception e) {
try {
LocalDateTime dateTime = LocalDateTime.parse(stringValue,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return dateTime.atZone(ZoneId.of("UTC")).toInstant();
} catch (Exception fallback) {
logger.warn("Instant konnte nicht geparst werden '{}': {}", stringValue, fallback.getMessage());
return null;
}
}
}
}
@@ -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.
* <p>
* Die Felder {@code overall_status}, {@code content_error_count},
* {@code transient_error_count} und {@code last_failure_instant} werden innerhalb
* der laufenden Transaktion per direktem SQL-UPDATE aktualisiert.
* Alle anderen Felder sowie alle {@code processing_attempt}-Einträge bleiben unverändert.
* <p>
* Ist kein Stammsatz für den Fingerprint vorhanden, kehrt die Methode stillschweigend zurück.
*
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
String sql = """
UPDATE document_record SET
overall_status = 'READY_FOR_AI',
content_error_count = 0,
transient_error_count = 0,
last_failure_instant = NULL
WHERE fingerprint = ?
""";
try (java.sql.PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, fingerprint.sha256Hex());
stmt.executeUpdate();
logger.debug("Status-Reset (feldgenau) für Fingerprint: {}", fingerprint.sha256Hex());
} catch (java.sql.SQLException e) {
String message = "Status-Reset fehlgeschlagen für Fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
}
}
@@ -60,5 +60,28 @@ public interface UnitOfWorkPort {
* @throws DocumentPersistenceException if the delete fails due to a technical error
*/
void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
/**
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
* ohne die Versuchshistorie zu löschen.
* <p>
* Folgende Felder werden aktualisiert:
* <ul>
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
* <li>{@code content_error_count} → {@code 0}</li>
* <li>{@code transient_error_count} → {@code 0}</li>
* <li>{@code last_failure_instant} → {@code null}</li>
* </ul>
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
* {@code last_target_path}, {@code last_target_file_name} sowie alle
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
* <p>
* Nach diesem Aufruf gilt das Dokument beim nächsten Lauf als verarbeitbar.
*
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
*/
void resetDocumentStatusForRetry(DocumentFingerprint fingerprint);
}
}
@@ -0,0 +1,50 @@
package de.gecheckt.pdf.umbenenner.application.port.out.history;
import java.time.Instant;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
/**
* Einzelzeile der Dokumentenliste im Historien-Tab.
* <p>
* Enthält alle Felder, die für die linke Tabelle des Historien-Tabs benötigt werden.
* Die Felder stammen aus {@code document_record} und einem {@code COUNT}-Ausdruck über
* {@code processing_attempt}.
*
* @param fingerprint Inhalts-basierter Dokumentbezeichner; nie {@code null}
* @param overallStatus aktueller Gesamtstatus des Dokuments; nie {@code null}
* @param sourceFileName zuletzt bekannter Quelldateiname; nie {@code null}
* @param targetFileName zuletzt bekannter Zieldateiname; {@code null} falls noch kein
* erfolgreicher Lauf stattgefunden hat
* @param sourcePath zuletzt bekannter Quellpfad (opaker Locator-Wert); nie {@code null}
* @param updatedAt Zeitpunkt der letzten Aktualisierung des Stammsatzes; nie {@code null}
* @param attemptCount Anzahl historisierter Verarbeitungsversuche; immer &gt;= 0
*/
public record DocumentHistoryRow(
DocumentFingerprint fingerprint,
ProcessingStatus overallStatus,
String sourceFileName,
String targetFileName,
String sourcePath,
Instant updatedAt,
long attemptCount) {
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws NullPointerException wenn ein Pflichtfeld {@code null} ist
* @throws IllegalArgumentException wenn {@code attemptCount} negativ ist
*/
public DocumentHistoryRow {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
Objects.requireNonNull(overallStatus, "overallStatus darf nicht null sein");
Objects.requireNonNull(sourceFileName, "sourceFileName darf nicht null sein");
Objects.requireNonNull(sourcePath, "sourcePath darf nicht null sein");
Objects.requireNonNull(updatedAt, "updatedAt darf nicht null sein");
if (attemptCount < 0) {
throw new IllegalArgumentException("attemptCount darf nicht negativ sein, war: " + attemptCount);
}
}
}
@@ -0,0 +1,65 @@
package de.gecheckt.pdf.umbenenner.application.port.out.history;
/**
* Abfrageparameter für den Historien-Tab.
* <p>
* Kapselt Freitextsuche, optionalen Status-Filter und das Limit der zurückzugebenden
* Zeilen. Das Limit ist bewusst auf 501 gesetzt, damit die aufrufende Schicht erkennen
* kann, ob mehr als 500 Treffer vorhanden sind.
*
* @param searchText optionaler Suchbegriff (Teilstring, case-insensitiv); {@code null}
* oder leer bedeutet keine Texteinschränkung
* @param statusFilter optionaler Status-Filter als Enum-Name; {@code null} bedeutet alle
* Status werden angezeigt
* @param limit maximale Anzahl zurückzugebender Zeilen; muss &gt;= 1 sein
*/
public record HistoryQuery(
String searchText,
String statusFilter,
int limit) {
/**
* Standard-Limit: 501 Zeilen abfragen, um bei Bedarf „mehr vorhanden" erkennen zu können.
*/
public static final int DEFAULT_LIMIT = 501;
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws IllegalArgumentException wenn {@code limit} kleiner als 1 ist
*/
public HistoryQuery {
if (limit < 1) {
throw new IllegalArgumentException("limit muss mindestens 1 sein, war: " + limit);
}
}
/**
* Erzeugt eine Abfrage ohne Filter mit Standard-Limit.
*
* @return neue Abfrage ohne Einschränkungen
*/
public static HistoryQuery unfiltered() {
return new HistoryQuery(null, null, DEFAULT_LIMIT);
}
/**
* Erzeugt eine Abfrage mit Freitextsuche und Standard-Limit.
*
* @param searchText Suchbegriff; {@code null} oder leer bedeutet kein Filter
* @return neue Abfrage mit Textfilter
*/
public static HistoryQuery withSearchText(String searchText) {
return new HistoryQuery(searchText, null, DEFAULT_LIMIT);
}
/**
* Erzeugt eine Abfrage mit Status-Filter und Standard-Limit.
*
* @param statusFilter Enum-Name des gewünschten Status; {@code null} bedeutet kein Filter
* @return neue Abfrage mit Status-Filter
*/
public static HistoryQuery withStatus(String statusFilter) {
return new HistoryQuery(null, statusFilter, DEFAULT_LIMIT);
}
}
@@ -0,0 +1,61 @@
package de.gecheckt.pdf.umbenenner.application.port.out.history;
import java.util.List;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Outbound-Port für lesende Historien-Abfragen aus dem Historien-Tab.
* <p>
* Kapselt alle Datenbanklese-Operationen, die der Historien-Tab benötigt.
* Die Implementierung liegt ausschließlich in {@code pdf-umbenenner-adapter-out}.
* Die Application-Schicht kennt nur diesen Port-Vertrag keine JDBC-Typen.
*
* <h2>Architektur</h2>
* <p>
* Dieser Port ist bewusst von {@link de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository}
* und {@link de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository}
* getrennt, damit die bestehenden Repositories nicht mit GUI-spezifischen Methoden
* aufgebläht werden.
*/
public interface HistoryQueryPort {
/**
* Lädt eine gefilterte und sortierte Übersicht aller Dokumenteneinträge.
* <p>
* Sortierung: {@code updated_at DESC, fingerprint ASC} (stabiler Tie-Breaker).
* Das in {@link HistoryQuery#limit()} angegebene Limit wird direkt als SQL-{@code LIMIT}
* angewendet. Wenn das Limit 501 beträgt und 501 Zeilen zurückgegeben werden, gibt es
* mehr als 500 Treffer.
*
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
* technischen Datenbankfehlern
*/
List<DocumentHistoryRow> loadOverview(HistoryQuery query);
/**
* Lädt den vollständigen Dokumenten-Stammsatz für den angegebenen Fingerprint.
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
* technischen Datenbankfehlern
*/
Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint);
/**
* Lädt alle historisierten Verarbeitungsversuche für den angegebenen Fingerprint,
* aufsteigend sortiert nach {@code attempt_number}.
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return unveränderliche Liste der Versuche; nie {@code null}; kann leer sein
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
* technischen Datenbankfehlern
*/
List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint);
}
@@ -0,0 +1,12 @@
/**
* Outbound-Ports und DTOs für lesende Historien-Abfragen des Historien-Tabs.
* <p>
* Enthält den {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort}
* sowie die zugehörigen Datentypen
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery} und
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow}.
* Diese Typen sind bewusst vom bestehenden {@code port.out}-Paket getrennt,
* damit die allgemeinen Repository-Schnittstellen nicht mit GUI-spezifischen Methoden
* belastet werden.
*/
package de.gecheckt.pdf.umbenenner.application.port.out.history;
@@ -0,0 +1,65 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Use-Case-Implementierung für das vollständige Löschen eines Dokumenteintrags
* aus dem Historien-Tab.
* <p>
* Löscht innerhalb einer Transaktion in der korrekten Reihenfolge, um den
* Foreign-Key-Constraint zwischen {@code processing_attempt.fingerprint} und
* {@code document_record.fingerprint} zu erfüllen (kein {@code ON DELETE CASCADE}):
* <ol>
* <li>Alle {@code processing_attempt}-Einträge zum Fingerprint</li>
* <li>Den {@code document_record}-Stammsatz zum Fingerprint</li>
* </ol>
* Die Operation ist idempotent: wenn kein Datensatz für den Fingerprint existiert,
* kehrt die Methode stillschweigend zurück.
* <p>
* <strong>Hinweis:</strong> Diese Aktion ist destruktiv und nicht rückgängig zu machen.
* Die GUI muss vor dem Aufruf einen Bestätigungsdialog anzeigen.
*/
public class DefaultDeleteDocumentHistoryUseCase {
private static final Logger logger = LogManager.getLogger(DefaultDeleteDocumentHistoryUseCase.class);
private final UnitOfWorkPort unitOfWorkPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
*
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
*/
public DefaultDeleteDocumentHistoryUseCase(UnitOfWorkPort unitOfWorkPort) {
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
}
/**
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
* <p>
* Die Löschung erfolgt in einer einzigen Transaktion. Versuche werden vor dem
* Stammsatz gelöscht, damit der Foreign-Key-Constraint eingehalten wird.
*
* @param fingerprint der Dokumentbezeichner, dessen Daten vollständig gelöscht werden sollen;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
* @throws NullPointerException wenn {@code fingerprint} null ist
*/
public void deleteHistory(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
// Nutzung der bestehenden Transaktion mit korrekter Löschreihenfolge:
// zuerst Versuche, dann Stammsatz (FK-Constraint)
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentByFingerprint(fingerprint));
logger.info("Dokumenteintrag vollständig gelöscht für Fingerprint: {}", fingerprint.sha256Hex());
}
}
@@ -0,0 +1,74 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Use-Case-Implementierung für das Laden der Detailansicht eines Dokuments im Historien-Tab.
* <p>
* Kombiniert den Dokument-Stammsatz und alle historisierten Verarbeitungsversuche
* für einen bestimmten Fingerprint in einem einzigen Ergebnisobjekt.
* <p>
* Wird kein Stammsatz gefunden (z. B. weil das Dokument zwischenzeitlich gelöscht wurde),
* liefert {@link #loadDetails(DocumentFingerprint)} ein leeres {@link Optional}.
*/
public class DefaultHistoryDetailsUseCase {
private final HistoryQueryPort historyQueryPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
*
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code historyQueryPort} null ist
*/
public DefaultHistoryDetailsUseCase(HistoryQueryPort historyQueryPort) {
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
}
/**
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
*
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
* @return Optional mit den Detaildaten, oder leer wenn kein Stammsatz gefunden wurde
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
public Optional<HistoryDetailsResult> loadDetails(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
Optional<DocumentRecord> record = historyQueryPort.findRecordByFingerprint(fingerprint);
if (record.isEmpty()) {
return Optional.empty();
}
List<ProcessingAttempt> attempts = historyQueryPort.findAttemptsByFingerprint(fingerprint);
return Optional.of(new HistoryDetailsResult(record.get(), attempts));
}
/**
* Ergebnis einer Historien-Detailabfrage.
*
* @param record Dokument-Stammsatz; nie {@code null}
* @param attempts alle historisierten Verarbeitungsversuche aufsteigend nach Versuchsnummer;
* nie {@code null}; kann leer sein
*/
public record HistoryDetailsResult(DocumentRecord record, List<ProcessingAttempt> attempts) {
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws NullPointerException wenn {@code record} oder {@code attempts} null ist
*/
public HistoryDetailsResult {
Objects.requireNonNull(record, "record darf nicht null sein");
Objects.requireNonNull(attempts, "attempts darf nicht null sein");
}
}
}
@@ -0,0 +1,82 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.List;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
/**
* Use-Case-Implementierung für das Laden der Dokumentenliste im Historien-Tab.
* <p>
* Delegiert die Datenbankabfrage vollständig an {@link HistoryQueryPort} und
* wertet das LIMIT-501-Ergebnis aus, um der GUI signalisieren zu können, ob
* weitere Einträge vorhanden sind, die durch einen engeren Filter erreichbar wären.
* <p>
* <strong>LIMIT-501-Technik:</strong> Die Query wird mit {@code limit + 1 = 501}
* ausgeführt (sofern das übergebene Limit 500 beträgt). Wenn die Datenbank 501
* Zeilen zurückgibt, existieren mehr als 500 Treffer. Die zurückgegebene Liste
* enthält dann exakt 500 Zeilen (das letzte Element wird verworfen) und
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
*/
public class DefaultHistoryOverviewUseCase {
private static final int MAX_DISPLAY_COUNT = 500;
private final HistoryQueryPort historyQueryPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
*
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code historyQueryPort} null ist
*/
public DefaultHistoryOverviewUseCase(HistoryQueryPort historyQueryPort) {
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
}
/**
* Lädt die Dokumentenliste auf Basis der übergebenen Abfrageparameter.
* <p>
* Intern wird ein Limit von 501 verwendet, um erkennen zu können, ob mehr
* als 500 Treffer vorhanden sind.
*
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
* @return Ergebnisobjekt mit Trefferlist und {@code hasMore}-Flag; nie {@code null}
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
* bei technischen Datenbankfehlern
*/
public HistoryOverviewResult loadOverview(HistoryQuery query) {
Objects.requireNonNull(query, "query darf nicht null sein");
List<DocumentHistoryRow> rows = historyQueryPort.loadOverview(query);
if (rows.size() > MAX_DISPLAY_COUNT) {
// 501 Zeilen zurückgegeben: mehr als 500 Treffer vorhanden
List<DocumentHistoryRow> truncated = List.copyOf(rows.subList(0, MAX_DISPLAY_COUNT));
return new HistoryOverviewResult(truncated, true);
}
return new HistoryOverviewResult(List.copyOf(rows), false);
}
/**
* Ergebnis einer Historien-Übersichtsabfrage.
*
* @param rows Liste der Trefferzeilen; nie {@code null}; enthält maximal 500 Einträge
* @param hasMore {@code true}, wenn mehr als 500 Treffer vorhanden sind und durch
* einen engeren Filter eingegrenzt werden könnten
*/
public record HistoryOverviewResult(List<DocumentHistoryRow> rows, boolean hasMore) {
/**
* Kompakter Konstruktor mit Pflichtfeldprüfung.
*
* @throws NullPointerException wenn {@code rows} null ist
*/
public HistoryOverviewResult {
Objects.requireNonNull(rows, "rows darf nicht null sein");
}
}
}
@@ -0,0 +1,69 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Use-Case-Implementierung für den feldgenauen Status-Reset aus dem Historien-Tab.
* <p>
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
* ohne die Versuchshistorie zu löschen:
* <ul>
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
* <li>{@code content_error_count} → {@code 0}</li>
* <li>{@code transient_error_count} → {@code 0}</li>
* <li>{@code last_failure_instant} → {@code null}</li>
* </ul>
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
* {@code last_target_path}, {@code last_target_file_name} sowie alle
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
* <p>
* Nach dem Reset gilt das Dokument beim nächsten Verarbeitungslauf als verarbeitbar,
* da {@code READY_FOR_AI} der einzige Trigger für die Verarbeitungslogik ist.
* <p>
* <strong>Abgrenzung:</strong> Dieser Use-Case unterscheidet sich von
* {@link DefaultResetDocumentStatusUseCase}, der alle Persistenzdaten (Stammsatz und
* Versuchshistorie) vollständig löscht und das Dokument so behandelt, als wäre es
* noch nie verarbeitet worden.
*/
public class DefaultHistoryResetDocumentStatusUseCase {
private static final Logger logger = LogManager.getLogger(DefaultHistoryResetDocumentStatusUseCase.class);
private final UnitOfWorkPort unitOfWorkPort;
/**
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
*
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
*/
public DefaultHistoryResetDocumentStatusUseCase(UnitOfWorkPort unitOfWorkPort) {
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
}
/**
* Führt den feldgenauen Status-Reset für den angegebenen Fingerprint durch.
* <p>
* Die Operation ist atomar: entweder werden alle vier Felder aktualisiert,
* oder keine Änderung findet statt (Rollback).
*
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
* darf nicht {@code null} sein
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
* @throws NullPointerException wenn {@code fingerprint} null ist
*/
public void resetStatus(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentStatusForRetry(fingerprint));
logger.info("Feldgenauer Status-Reset durchgeführt für Fingerprint: {}", fingerprint.sha256Hex());
}
}
@@ -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);
@@ -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
}
});
}
}
@@ -0,0 +1,144 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Tests für {@link DefaultDeleteDocumentHistoryUseCase}.
* <p>
* Prüft, dass ausschließlich {@code resetDocumentByFingerprint} aufgerufen wird
* (vollständige Löschung inklusive Versuchen, FK-sicher), Null-Guards greifen
* und Port-Fehler propagiert werden.
*/
class DefaultDeleteDocumentHistoryUseCaseTest {
private static final DocumentFingerprint FP =
new DocumentFingerprint("b".repeat(64));
// -------------------------------------------------------------------------
// Null-Guards
// -------------------------------------------------------------------------
@Test
void constructor_nullPort_throwsNPE() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultDeleteDocumentHistoryUseCase(null));
}
@Test
void deleteHistory_nullFingerprint_throwsNPE() {
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(noOpPort());
assertThatNullPointerException()
.isThrownBy(() -> useCase.deleteHistory(null));
}
// -------------------------------------------------------------------------
// Happy path: vollständige Löschung
// -------------------------------------------------------------------------
@Test
void deleteHistory_callsResetDocumentByFingerprint() {
RecordingTransactionOperations ops = new RecordingTransactionOperations();
UnitOfWorkPort port = operations -> operations.accept(ops);
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(port);
useCase.deleteHistory(FP);
assertThat(ops.resetByFingerprintFingerprints)
.containsExactly(FP);
}
@Test
void deleteHistory_doesNotCallResetDocumentStatusForRetry() {
RecordingTransactionOperations ops = new RecordingTransactionOperations();
UnitOfWorkPort port = operations -> operations.accept(ops);
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(port);
useCase.deleteHistory(FP);
assertThat(ops.resetStatusForRetryFingerprints).isEmpty();
}
// -------------------------------------------------------------------------
// Port-Fehler wird propagiert
// -------------------------------------------------------------------------
@Test
void deleteHistory_portThrows_exceptionPropagated() {
UnitOfWorkPort failingPort = operations ->
operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override
public void saveProcessingAttempt(ProcessingAttempt attempt) { }
@Override
public void createDocumentRecord(DocumentRecord record) { }
@Override
public void updateDocumentRecord(DocumentRecord record) { }
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
throw new DocumentPersistenceException("Simulated DB error");
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
});
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(failingPort);
assertThatThrownBy(() -> useCase.deleteHistory(FP))
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("Simulated DB error");
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static UnitOfWorkPort noOpPort() {
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
@Override public void createDocumentRecord(DocumentRecord r) { }
@Override public void updateDocumentRecord(DocumentRecord r) { }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
});
}
/**
* Zeichnet {@code resetDocumentByFingerprint}- und {@code resetDocumentStatusForRetry}-Aufrufe auf.
*/
private static class RecordingTransactionOperations
implements UnitOfWorkPort.TransactionOperations {
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
@Override public void createDocumentRecord(DocumentRecord r) { }
@Override public void updateDocumentRecord(DocumentRecord r) { }
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
resetByFingerprintFingerprints.add(fingerprint);
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
resetStatusForRetryFingerprints.add(fingerprint);
}
}
}
@@ -0,0 +1,215 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
* Tests für {@link DefaultHistoryDetailsUseCase}.
* <p>
* Prüft den Happy-Path (Stammsatz vorhanden), das leere-Optional-Verhalten
* (kein Stammsatz), Null-Guards und Port-Fehler-Propagation.
*/
class DefaultHistoryDetailsUseCaseTest {
private static final DocumentFingerprint FP =
new DocumentFingerprint("a".repeat(64));
// -------------------------------------------------------------------------
// Null-Guards
// -------------------------------------------------------------------------
@Test
void constructor_nullPort_throwsNPE() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultHistoryDetailsUseCase(null));
}
@Test
void loadDetails_nullFingerprint_throwsNPE() {
DefaultHistoryDetailsUseCase useCase =
new DefaultHistoryDetailsUseCase(emptyPort());
assertThatNullPointerException()
.isThrownBy(() -> useCase.loadDetails(null));
}
// -------------------------------------------------------------------------
// Kein Stammsatz vorhanden
// -------------------------------------------------------------------------
@Test
void loadDetails_noRecord_returnsEmpty() {
DefaultHistoryDetailsUseCase useCase =
new DefaultHistoryDetailsUseCase(emptyPort());
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
assertThat(result).isEmpty();
}
// -------------------------------------------------------------------------
// Happy path: Stammsatz vorhanden, Versuche vorhanden
// -------------------------------------------------------------------------
@Test
void loadDetails_recordExists_returnsResultWithRecordAndAttempts() {
DocumentRecord record = buildRecord(FP);
ProcessingAttempt attempt = buildAttempt(FP);
HistoryQueryPort port = new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return Collections.emptyList();
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.of(record);
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return List.of(attempt);
}
};
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
assertThat(result).isPresent();
assertThat(result.get().record()).isSameAs(record);
assertThat(result.get().attempts()).containsExactly(attempt);
}
// -------------------------------------------------------------------------
// Stammsatz vorhanden, keine Versuche
// -------------------------------------------------------------------------
@Test
void loadDetails_recordExistsNoAttempts_returnsResultWithEmptyAttempts() {
DocumentRecord record = buildRecord(FP);
HistoryQueryPort port = new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return Collections.emptyList();
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.of(record);
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
assertThat(result).isPresent();
assertThat(result.get().attempts()).isEmpty();
}
// -------------------------------------------------------------------------
// Port-Fehler wird propagiert
// -------------------------------------------------------------------------
@Test
void loadDetails_portThrowsOnRecord_exceptionPropagated() {
HistoryQueryPort failingPort = new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return Collections.emptyList();
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
throw new DocumentPersistenceException("Simulated DB error");
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(failingPort);
assertThatThrownBy(() -> useCase.loadDetails(FP))
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("Simulated DB error");
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static HistoryQueryPort emptyPort() {
return new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return Collections.emptyList();
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.empty();
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
}
private static DocumentRecord buildRecord(DocumentFingerprint fp) {
return new DocumentRecord(
fp,
new SourceDocumentLocator("/source"),
"source.pdf",
ProcessingStatus.SUCCESS,
new FailureCounters(0, 0),
null,
Instant.now(),
Instant.now(),
Instant.now(),
"/target",
"2024-01-01 - Dokument.pdf");
}
private static ProcessingAttempt buildAttempt(DocumentFingerprint fp) {
return ProcessingAttempt.withoutAiFields(
fp,
new de.gecheckt.pdf.umbenenner.domain.model.RunId(
java.util.UUID.randomUUID().toString()),
1,
Instant.now(),
Instant.now(),
ProcessingStatus.SUCCESS,
null,
null,
false);
}
}
@@ -0,0 +1,199 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
/**
* Tests für {@link DefaultHistoryOverviewUseCase}.
* <p>
* Prüft den Happy-Path, das LIMIT-501-Verhalten und Null-Guards.
*/
class DefaultHistoryOverviewUseCaseTest {
private static final DocumentFingerprint FP =
new DocumentFingerprint("a".repeat(64));
// -------------------------------------------------------------------------
// Null-Guards
// -------------------------------------------------------------------------
@Test
void constructor_nullPort_throwsNPE() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultHistoryOverviewUseCase(null));
}
@Test
void loadOverview_nullQuery_throwsNPE() {
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(emptyPort());
assertThatNullPointerException()
.isThrownBy(() -> useCase.loadOverview(null));
}
// -------------------------------------------------------------------------
// Happy path: leer
// -------------------------------------------------------------------------
@Test
void loadOverview_emptyDatabase_returnsEmptyResultWithoutMore() {
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(emptyPort());
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThat(result.rows()).isEmpty();
assertThat(result.hasMore()).isFalse();
}
// -------------------------------------------------------------------------
// Happy path: weniger als 500 Treffer
// -------------------------------------------------------------------------
@Test
void loadOverview_fewerThan500Results_returnsAllRowsWithoutMore() {
List<DocumentHistoryRow> rows = buildRows(10);
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(fixedPort(rows));
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThat(result.rows()).hasSize(10);
assertThat(result.hasMore()).isFalse();
}
// -------------------------------------------------------------------------
// LIMIT-501-Technik
// -------------------------------------------------------------------------
@Test
void loadOverview_exactly500Results_returnsAllWithoutMore() {
List<DocumentHistoryRow> rows = buildRows(500);
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(fixedPort(rows));
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThat(result.rows()).hasSize(500);
assertThat(result.hasMore()).isFalse();
}
@Test
void loadOverview_moreThan500Results_returns500RowsWithHasMore() {
List<DocumentHistoryRow> rows = buildRows(501);
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(fixedPort(rows));
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThat(result.rows()).hasSize(500);
assertThat(result.hasMore()).isTrue();
}
@Test
void loadOverview_resultListIsImmutable() {
List<DocumentHistoryRow> rows = buildRows(3);
DefaultHistoryOverviewUseCase useCase =
new DefaultHistoryOverviewUseCase(fixedPort(rows));
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
assertThatThrownBy(() -> result.rows().add(buildRow("0".repeat(64))))
.isInstanceOf(UnsupportedOperationException.class);
}
// -------------------------------------------------------------------------
// Port-Fehler wird propagiert
// -------------------------------------------------------------------------
@Test
void loadOverview_portThrows_exceptionPropagated() {
HistoryQueryPort failingPort = new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
throw new DocumentPersistenceException("Simulated DB error");
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.empty();
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(failingPort);
assertThatThrownBy(() -> useCase.loadOverview(HistoryQuery.unfiltered()))
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("Simulated DB error");
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static HistoryQueryPort emptyPort() {
return fixedPort(Collections.emptyList());
}
private static HistoryQueryPort fixedPort(List<DocumentHistoryRow> rows) {
return new HistoryQueryPort() {
@Override
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
return new ArrayList<>(rows);
}
@Override
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
return Optional.empty();
}
@Override
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
return Collections.emptyList();
}
};
}
private static List<DocumentHistoryRow> buildRows(int count) {
List<DocumentHistoryRow> result = new ArrayList<>();
for (int i = 0; i < count; i++) {
String hex = String.format("%064x", i);
result.add(buildRow(hex));
}
return result;
}
private static DocumentHistoryRow buildRow(String fpHex) {
return new DocumentHistoryRow(
new DocumentFingerprint(fpHex),
ProcessingStatus.SUCCESS,
"source.pdf",
"2024-01-01 - Dokument.pdf",
"/source",
Instant.now(),
1L);
}
}
@@ -0,0 +1,147 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Tests für {@link DefaultHistoryResetDocumentStatusUseCase}.
* <p>
* Prüft, dass ausschließlich {@code resetDocumentStatusForRetry} aufgerufen wird
* (nicht {@code resetDocumentByFingerprint}), Null-Guards greifen und
* Port-Fehler propagiert werden.
*/
class DefaultHistoryResetDocumentStatusUseCaseTest {
private static final DocumentFingerprint FP =
new DocumentFingerprint("a".repeat(64));
// -------------------------------------------------------------------------
// Null-Guards
// -------------------------------------------------------------------------
@Test
void constructor_nullPort_throwsNPE() {
assertThatNullPointerException()
.isThrownBy(() -> new DefaultHistoryResetDocumentStatusUseCase(null));
}
@Test
void resetStatus_nullFingerprint_throwsNPE() {
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(noOpPort());
assertThatNullPointerException()
.isThrownBy(() -> useCase.resetStatus(null));
}
// -------------------------------------------------------------------------
// Happy path: feldgenauer Reset
// -------------------------------------------------------------------------
@Test
void resetStatus_callsResetDocumentStatusForRetry() {
RecordingTransactionOperations ops = new RecordingTransactionOperations();
UnitOfWorkPort port = operations -> operations.accept(ops);
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(port);
useCase.resetStatus(FP);
assertThat(ops.resetStatusForRetryFingerprints)
.containsExactly(FP);
assertThat(ops.resetByFingerprintFingerprints)
.isEmpty();
}
@Test
void resetStatus_doesNotCallResetDocumentByFingerprint() {
RecordingTransactionOperations ops = new RecordingTransactionOperations();
UnitOfWorkPort port = operations -> operations.accept(ops);
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(port);
useCase.resetStatus(FP);
assertThat(ops.resetByFingerprintFingerprints).isEmpty();
}
// -------------------------------------------------------------------------
// Port-Fehler wird propagiert
// -------------------------------------------------------------------------
@Test
void resetStatus_portThrows_exceptionPropagated() {
UnitOfWorkPort failingPort = operations ->
operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override
public void saveProcessingAttempt(ProcessingAttempt attempt) { }
@Override
public void createDocumentRecord(DocumentRecord record) { }
@Override
public void updateDocumentRecord(DocumentRecord record) { }
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
throw new DocumentPersistenceException("Simulated DB error");
}
});
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(failingPort);
assertThatThrownBy(() -> useCase.resetStatus(FP))
.isInstanceOf(DocumentPersistenceException.class)
.hasMessageContaining("Simulated DB error");
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static UnitOfWorkPort noOpPort() {
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
@Override public void createDocumentRecord(DocumentRecord r) { }
@Override public void updateDocumentRecord(DocumentRecord r) { }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
});
}
/**
* Zeichnet {@code resetDocumentStatusForRetry}- und {@code resetDocumentByFingerprint}-Aufrufe auf.
*/
private static class RecordingTransactionOperations
implements UnitOfWorkPort.TransactionOperations {
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
@Override public void createDocumentRecord(DocumentRecord r) { }
@Override public void updateDocumentRecord(DocumentRecord r) { }
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
resetByFingerprintFingerprints.add(fingerprint);
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
resetStatusForRetryFingerprints.add(fingerprint);
}
}
}
@@ -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) { }
}
}
@@ -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) { }
}
}
@@ -216,5 +216,10 @@ class DefaultResetDocumentStatusUseCaseTest {
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
recorded.add(fingerprint);
}
@Override
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
// No-op in tests
}
}
}
@@ -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.
* <p>
* 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.
* <p>
* 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<DefaultHistoryDetailsUseCase.HistoryDetailsResult> 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.
* <p>
* 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.
* <p>
* 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.