#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:
+16
-2
@@ -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
|
||||
|
||||
+52
-5
@@ -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 */ };
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -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);
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
+794
@@ -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();
|
||||
}
|
||||
}
|
||||
+15
@@ -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;
|
||||
Reference in New Issue
Block a user