#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;