diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
index 15d718a..1e5da5b 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
@@ -17,6 +17,7 @@ import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalFileNamePort;
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;
@@ -371,6 +372,12 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiManualFileRenamePort manualFileRenamePort;
+ /**
+ * Port used by the processing-run coordinator to resolve the historical AI-proposed
+ * filename for skipped documents. Supplied by Bootstrap via the startup context.
+ */
+ private final GuiHistoricalFileNamePort historicalFileNamePort;
+
/**
* Second main tab of the window that drives the live processing-run view. Created
* during workspace construction and wired into the shared {@link #tabPane} alongside
@@ -446,6 +453,7 @@ public final class GuiConfigurationEditorWorkspace {
this.miniRunLauncher = effectiveContext.miniRunLauncher();
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
+ this.historicalFileNamePort = effectiveContext.historicalFileNamePort();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
() -> this.miniRunLauncher,
@@ -454,6 +462,7 @@ public final class GuiConfigurationEditorWorkspace {
this::isSavedConfigurationReady,
this::applyBatchRunLockState,
() -> this.manualFileRenamePort,
+ () -> this.historicalFileNamePort,
this::editorSourceFolder,
this::editorTargetFolder);
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
index 4d5eb45..5e47a3d 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
@@ -6,6 +6,7 @@ import java.util.Set;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalFileNamePort;
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;
@@ -40,7 +41,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
* reset the persistence status of selected documents, and the
- * {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI.
+ * {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI, and
+ * the {@link GuiHistoricalFileNamePort} used to retrieve the historical AI-proposed filename
+ * for documents that were skipped in the current run.
*
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
* know about provider-specific HTTP details or adapter wiring.
@@ -59,7 +62,8 @@ public record GuiStartupContext(
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
- GuiManualFileRenamePort manualFileRenamePort) {
+ GuiManualFileRenamePort manualFileRenamePort,
+ GuiHistoricalFileNamePort historicalFileNamePort) {
/**
* Creates a fully wired startup context.
@@ -81,6 +85,8 @@ public record GuiStartupContext(
* documents; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
* must not be {@code null}
+ * @param historicalFileNamePort bridge that resolves the historical AI-proposed filename
+ * for skipped documents; must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -109,6 +115,8 @@ public record GuiStartupContext(
"resetDocumentStatusPort must not be null");
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
"manualFileRenamePort must not be null");
+ historicalFileNamePort = Objects.requireNonNull(historicalFileNamePort,
+ "historicalFileNamePort must not be null");
}
/**
@@ -148,7 +156,8 @@ public record GuiStartupContext(
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
- miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort());
+ miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
+ noOpHistoricalFileNamePort());
}
/**
@@ -182,7 +191,8 @@ public record GuiStartupContext(
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
- rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort());
+ rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
+ noOpHistoricalFileNamePort());
}
/**
@@ -217,7 +227,7 @@ public record GuiStartupContext(
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
- rejectingManualFileRenamePort());
+ rejectingManualFileRenamePort(), noOpHistoricalFileNamePort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -246,6 +256,10 @@ public record GuiStartupContext(
"Kein Umbennennungs-Port in diesem Startkontext verfügbar.");
}
+ private static GuiHistoricalFileNamePort noOpHistoricalFileNamePort() {
+ return (configPath, fingerprint) -> java.util.Optional.empty();
+ }
+
/**
* Creates a blank startup context with no-op implementations for all ports and services.
*
@@ -319,6 +333,7 @@ public record GuiStartupContext(
noOpBatchRunLauncher,
rejectingMiniRunLauncher(),
rejectingResetPort(),
- rejectingManualFileRenamePort());
+ rejectingManualFileRenamePort(),
+ noOpHistoricalFileNamePort());
}
}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java
index 3d1c16a..8616360 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java
@@ -17,6 +17,7 @@ import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
@@ -112,6 +113,7 @@ public final class GuiBatchRunCoordinator {
private final Function threadFactory;
private final Consumer fxDispatcher;
private final Listener listener;
+ private final GuiHistoricalFileNamePort historicalFileNamePort;
private final AtomicReference activeWorker = new AtomicReference<>();
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
@@ -152,6 +154,27 @@ public final class GuiBatchRunCoordinator {
defaultThreadFactory(), defaultFxDispatcher(), listener);
}
+ /**
+ * Creates the coordinator with all ports and the historical file name port, using the
+ * default worker-thread factory and JavaFX Application Thread dispatcher.
+ *
+ * @param launcher bridge to Bootstrap for regular batch runs; must not be null
+ * @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
+ * @param resetPort bridge to Bootstrap for status-reset-only operations; must
+ * not be null
+ * @param listener GUI listener invoked on the FX thread; must not be null
+ * @param historicalFileNamePort port for resolving the historical AI-proposed filename for
+ * skipped documents; must not be null
+ */
+ public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
+ GuiMiniRunLauncher miniRunLauncher,
+ GuiResetDocumentStatusPort resetPort,
+ Listener listener,
+ GuiHistoricalFileNamePort historicalFileNamePort) {
+ this(launcher, miniRunLauncher, resetPort,
+ defaultThreadFactory(), defaultFxDispatcher(), listener, historicalFileNamePort);
+ }
+
/**
* Creates the coordinator with custom hooks for the worker-thread factory and the
* UI-thread dispatcher.
@@ -176,12 +199,43 @@ public final class GuiBatchRunCoordinator {
Function threadFactory,
Consumer fxDispatcher,
Listener listener) {
+ this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
+ noOpHistoricalFileNamePort());
+ }
+
+ /**
+ * Creates the coordinator with all ports, custom thread factory, FX dispatcher and
+ * historical file name port.
+ *
+ * This is the canonical constructor. All other constructors delegate here.
+ *
+ * @param launcher bridge to Bootstrap for regular batch runs; must not be null
+ * @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
+ * @param resetPort bridge to Bootstrap for status-reset-only operations; must
+ * not be null
+ * @param threadFactory factory returning a ready-to-start worker thread; must not
+ * be null
+ * @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
+ * Thread; must not be null
+ * @param listener GUI listener; must not be null
+ * @param historicalFileNamePort port for resolving the historical AI-proposed filename for
+ * skipped documents; must not be null
+ */
+ public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
+ GuiMiniRunLauncher miniRunLauncher,
+ GuiResetDocumentStatusPort resetPort,
+ Function threadFactory,
+ Consumer fxDispatcher,
+ Listener listener,
+ GuiHistoricalFileNamePort historicalFileNamePort) {
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
this.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
this.listener = Objects.requireNonNull(listener, "listener must not be null");
+ this.historicalFileNamePort = Objects.requireNonNull(
+ historicalFileNamePort, "historicalFileNamePort must not be null");
}
/**
@@ -382,7 +436,7 @@ public final class GuiBatchRunCoordinator {
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
configFilePath);
observerSummary.set(null);
- BatchRunProgressObserver observer = buildDispatchingObserver();
+ BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
try {
@@ -405,7 +459,7 @@ public final class GuiBatchRunCoordinator {
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
observerSummary.set(null);
- BatchRunProgressObserver observer = buildDispatchingObserver();
+ BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
try {
@@ -476,7 +530,7 @@ public final class GuiBatchRunCoordinator {
*/
private final AtomicReference observerSummary = new AtomicReference<>();
- private BatchRunProgressObserver buildDispatchingObserver() {
+ private BatchRunProgressObserver buildDispatchingObserver(Path configFilePath) {
return new BatchRunProgressObserver() {
@Override
public void onRunStarted(RunId runId, int totalCandidates) {
@@ -485,23 +539,44 @@ public final class GuiBatchRunCoordinator {
@Override
public void onDocumentCompleted(DocumentCompletionEvent event) {
- GuiBatchRunResultRow row = toRow(event);
+ GuiBatchRunResultRow row = toRow(event, configFilePath);
fxDispatcher.accept(() -> listener.onDocumentCompleted(row));
}
@Override
public void onRunEnded(RunSummary summary) {
observerSummary.set(summary);
- // No FX dispatch here: the worker thread invokes the listener's
- // onRunEnded via finishRun() once the launcher has returned, ensuring
- // the outcome carries the launcher's terminal verdict.
+ // Kein FX-Dispatch hier: der Worker-Thread ruft onRunEnded über finishRun()
+ // auf, nachdem der Launcher zurückgekehrt ist.
}
};
}
- private static GuiBatchRunResultRow toRow(DocumentCompletionEvent event) {
+ /**
+ * Wandelt ein {@link DocumentCompletionEvent} in eine {@link GuiBatchRunResultRow} um.
+ *
+ * Für übersprungene Dokumente ({@link DocumentCompletionStatus#SKIPPED}) ohne
+ * vorhandenen Dateinamen wird der historische KI-Dateiname über den
+ * {@link GuiHistoricalFileNamePort} nachgeladen. Schlägt die Abfrage fehl, bleibt
+ * die Spalte leer. Die Methode läuft auf dem Worker-Thread.
+ *
+ * @param event das abgeschlossene Kandidatenereignis; darf nicht {@code null} sein
+ * @param configFilePath Pfad zur aktiven Konfigurationsdatei; darf nicht {@code null} sein
+ * @return eine neue {@link GuiBatchRunResultRow}; nie {@code null}
+ */
+ private GuiBatchRunResultRow toRow(DocumentCompletionEvent event, Path configFilePath) {
Optional finalName = event.finalFileName() == null
? Optional.empty() : Optional.of(event.finalFileName());
+ // Historischen KI-Dateinamen für übersprungene Dokumente nachladen
+ if (finalName.isEmpty() && event.status() == DocumentCompletionStatus.SKIPPED) {
+ try {
+ finalName = historicalFileNamePort.resolveHistoricalFileName(
+ configFilePath, event.fingerprint());
+ } catch (Exception e) {
+ LOG.warn("Historischer Dateiname konnte nicht abgefragt werden für {}: {}",
+ event.originalFileName(), e.getMessage(), e);
+ }
+ }
Optional date = event.resolvedDate() == null
? Optional.empty() : Optional.of(event.resolvedDate());
Optional reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
@@ -520,6 +595,10 @@ public final class GuiBatchRunCoordinator {
duration);
}
+ private static GuiHistoricalFileNamePort noOpHistoricalFileNamePort() {
+ return (configPath, fingerprint) -> Optional.empty();
+ }
+
private static Function defaultThreadFactory() {
return task -> {
Thread thread = new Thread(task, WORKER_THREAD_NAME);
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java
index fdd4807..a3f8aa6 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java
@@ -193,6 +193,7 @@ public final class GuiBatchRunTab {
private final Runnable onRunStateChanged;
private final GuiBatchRunCoordinator coordinator;
private final Supplier manualFileRenamePortSupplier;
+ private final Supplier historicalFileNamePortSupplier;
private final Supplier> sourceFolderSupplier;
private final Supplier> targetFolderSupplier;
@@ -229,12 +230,14 @@ public final class GuiBatchRunTab {
* null sein
* @param onRunStateChanged Callback wenn das Lauf-Flag kippt; darf nicht
* null sein
- * @param manualFileRenamePortSupplier Supplier für den manuellen Umbennennungs-Port;
- * darf nicht null sein
- * @param sourceFolderSupplier Supplier für den konfigurierten Quellordner;
- * darf leeres Optional zurückliefern
- * @param targetFolderSupplier Supplier für den konfigurierten Zielordner als
- * Pfad-String; darf leeres Optional zurückliefern
+ * @param manualFileRenamePortSupplier Supplier für den manuellen Umbennennungs-Port;
+ * darf nicht null sein
+ * @param historicalFileNamePortSupplier Supplier für den historischen Dateiname-Port;
+ * darf nicht null sein
+ * @param sourceFolderSupplier Supplier für den konfigurierten Quellordner;
+ * darf leeres Optional zurückliefern
+ * @param targetFolderSupplier Supplier für den konfigurierten Zielordner als
+ * Pfad-String; darf leeres Optional zurückliefern
*/
public GuiBatchRunTab(Supplier launcherSupplier,
Supplier miniRunLauncherSupplier,
@@ -243,6 +246,7 @@ public final class GuiBatchRunTab {
BooleanSupplier savedConfigurationReadyCheck,
Runnable onRunStateChanged,
Supplier manualFileRenamePortSupplier,
+ Supplier historicalFileNamePortSupplier,
Supplier> sourceFolderSupplier,
Supplier> targetFolderSupplier) {
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
@@ -254,6 +258,8 @@ public final class GuiBatchRunTab {
this.onRunStateChanged = Objects.requireNonNull(onRunStateChanged, "onRunStateChanged must not be null");
this.manualFileRenamePortSupplier = Objects.requireNonNull(
manualFileRenamePortSupplier, "manualFileRenamePortSupplier must not be null");
+ this.historicalFileNamePortSupplier = Objects.requireNonNull(
+ historicalFileNamePortSupplier, "historicalFileNamePortSupplier must not be null");
this.sourceFolderSupplier = Objects.requireNonNull(
sourceFolderSupplier, "sourceFolderSupplier must not be null");
this.targetFolderSupplier = Objects.requireNonNull(
@@ -266,7 +272,8 @@ public final class GuiBatchRunTab {
miniRunLauncherSupplier.get().launch(configPath, filter, observer, token),
(configPath, fingerprints) ->
resetPortSupplier.get().reset(configPath, fingerprints),
- new CoordinatorListener());
+ new CoordinatorListener(),
+ historicalFileNamePortSupplier.get());
this.tab.setClosable(false);
this.tab.setContent(buildContent());
@@ -306,6 +313,7 @@ public final class GuiBatchRunTab {
savedConfigurationReadyCheck,
onRunStateChanged,
() -> GuiBatchRunTab::rejectingRename,
+ () -> (cfgPath, fp) -> Optional.empty(),
Optional::empty,
Optional::empty);
}
@@ -338,6 +346,7 @@ public final class GuiBatchRunTab {
savedConfigurationReadyCheck,
onRunStateChanged,
() -> GuiBatchRunTab::rejectingRename,
+ () -> (cfgPath, fp) -> Optional.empty(),
Optional::empty,
Optional::empty);
}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiHistoricalFileNamePort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiHistoricalFileNamePort.java
new file mode 100644
index 0000000..bb6d467
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiHistoricalFileNamePort.java
@@ -0,0 +1,38 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
+
+import java.nio.file.Path;
+import java.util.Optional;
+
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
+/**
+ * GUI-interner Port zum Abfragen des historischen KI-Dateinamens einer Quelldatei.
+ *
+ * Wird im Verarbeitungslauf-Tab genutzt, um für übersprungene Dokumente
+ * ({@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED})
+ * den aus einem früheren Lauf bekannten Zieldateinamen nachzuschlagen und in der Spalte
+ * „Neuer Dateiname" der Ergebnistabelle anzuzeigen.
+ *
+ * Die Bootstrap-Schicht liefert die konkrete Implementierung. Sie lädt die Konfiguration
+ * aus {@code configFilePath}, baut den zugehörigen Use-Case auf und gibt das Ergebnis
+ * zurück. Technische Fehler beim Laden oder Abfragen dürfen nicht als Exception propagiert
+ * werden; sie werden intern behandelt und als leeres {@link Optional} zurückgegeben.
+ *
+ * Die Implementierung läuft auf dem Worker-Thread des {@link GuiBatchRunCoordinator}
+ * und darf blockieren.
+ */
+@FunctionalInterface
+public interface GuiHistoricalFileNamePort {
+
+ /**
+ * Gibt den letzten erfolgreich geschriebenen Zieldateinamen für das durch
+ * {@code fingerprint} identifizierte Dokument zurück, oder ein leeres
+ * {@link Optional}, wenn kein solcher Name verfügbar ist.
+ *
+ * @param configFilePath Pfad zur aktiven {@code .properties}-Konfigurationsdatei;
+ * darf nicht {@code null} sein
+ * @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
+ * @return der historische Zieldateiname, oder leer wenn nicht vorhanden
+ */
+ Optional resolveHistoricalFileName(Path configFilePath, DocumentFingerprint fingerprint);
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResolveHistoricalFileNameUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResolveHistoricalFileNameUseCase.java
new file mode 100644
index 0000000..3bf0a39
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResolveHistoricalFileNameUseCase.java
@@ -0,0 +1,37 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Optional;
+
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
+/**
+ * Inbound port for resolving the historical target filename for a previously processed document.
+ *
+ * Used to populate the "Neuer Dateiname" column in the GUI result table for documents that
+ * were skipped in the current run because they were already in a terminal state from a
+ * previous run. For documents that previously reached
+ * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS}, the last
+ * successfully written target filename is returned. For all other terminal states
+ * (e.g. {@code FAILED_FINAL}) that were never copied to the target folder, an empty
+ * {@link Optional} is returned.
+ *
+ * Architecture boundary: Implementations of this port must not expose
+ * JDBC, SQLite, filesystem or HTTP types through this interface. All infrastructure details
+ * remain in the adapter layer.
+ */
+public interface ResolveHistoricalFileNameUseCase {
+
+ /**
+ * Returns the last successfully written target filename for the document identified
+ * by the given fingerprint, or an empty {@link Optional} if no such filename exists.
+ *
+ * The method never throws: technical failures during the repository lookup are caught
+ * and result in an empty return value.
+ *
+ * @param fingerprint content-based document identity; must not be {@code null}
+ * @return the last target filename written for this document, or empty if the document
+ * never reached a successful terminal state or the lookup failed
+ * @throws NullPointerException if {@code fingerprint} is {@code null}
+ */
+ Optional resolveHistoricalFileName(DocumentFingerprint fingerprint);
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java
index 9306486..c99bd79 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java
@@ -28,6 +28,13 @@
* — Event and summary value types carried to the observer
*
*
+ *
+ * Query use cases (for GUI adapters):
+ *
+ * - {@link de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalFileNameUseCase}
+ * — Resolves the last known target filename for a document identified by its fingerprint
+ *
+ *
* Architecture Rule: Inbound ports are independent of implementation and contain no business logic.
* They define "what can be done to the application". All dependencies point inward;
* adapters depend on ports, not vice versa.
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCase.java
new file mode 100644
index 0000000..ea42942
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCase.java
@@ -0,0 +1,69 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalFileNameUseCase;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
+/**
+ * Default implementation of {@link ResolveHistoricalFileNameUseCase}.
+ *
+ * Queries the {@link DocumentRecordRepository} for the master record of the given fingerprint.
+ * If the record represents a document that previously reached a successful terminal state,
+ * the last known target filename ({@code lastTargetFileName}) is returned.
+ *
+ * For all other terminal states (e.g. documents that finally failed without ever producing
+ * a target copy) or when no master record exists, an empty {@link Optional} is returned.
+ * Technical failures during the repository lookup are caught silently and treated as
+ * an absent result so that the calling GUI layer is never forced to handle exceptions
+ * from this query path.
+ */
+public class DefaultResolveHistoricalFileNameUseCase implements ResolveHistoricalFileNameUseCase {
+
+ private final DocumentRecordRepository documentRecordRepository;
+
+ /**
+ * Creates the use case with the required document master record repository.
+ *
+ * @param documentRecordRepository repository for reading document master records;
+ * must not be {@code null}
+ * @throws NullPointerException if {@code documentRecordRepository} is {@code null}
+ */
+ public DefaultResolveHistoricalFileNameUseCase(
+ DocumentRecordRepository documentRecordRepository) {
+ this.documentRecordRepository = Objects.requireNonNull(
+ documentRecordRepository, "documentRecordRepository must not be null");
+ }
+
+ /**
+ * Returns the last successfully written target filename for the given fingerprint,
+ * or an empty {@link Optional} if no such filename exists.
+ *
+ * The method queries the document master record. Only documents with a prior
+ * successful terminal state carry a non-null {@code lastTargetFileName}. For all
+ * other states (unknown, processable, finally failed) and on any technical lookup
+ * failure, an empty {@link Optional} is returned.
+ *
+ * @param fingerprint content-based document identity; must not be {@code null}
+ * @return the historical target filename, or empty if not available
+ * @throws NullPointerException if {@code fingerprint} is {@code null}
+ */
+ @Override
+ public Optional resolveHistoricalFileName(DocumentFingerprint fingerprint) {
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
+ try {
+ DocumentRecordLookupResult result =
+ documentRecordRepository.findByFingerprint(fingerprint);
+ if (result instanceof DocumentTerminalSuccess success) {
+ return Optional.ofNullable(success.record().lastTargetFileName());
+ }
+ return Optional.empty();
+ } catch (Exception e) {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCaseTest.java
new file mode 100644
index 0000000..f8a0440
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultResolveHistoricalFileNameUseCaseTest.java
@@ -0,0 +1,152 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+import java.time.Instant;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
+import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
+import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
+import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
+
+/**
+ * Unit tests for {@link DefaultResolveHistoricalFileNameUseCase}.
+ */
+class DefaultResolveHistoricalFileNameUseCaseTest {
+
+ private static final DocumentFingerprint FP = new DocumentFingerprint("a".repeat(64));
+
+ @Test
+ void constructor_withNullRepository_throws() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new DefaultResolveHistoricalFileNameUseCase(null))
+ .withMessageContaining("documentRecordRepository");
+ }
+
+ @Test
+ void resolveHistoricalFileName_withNullFingerprint_throws() {
+ var useCase = new DefaultResolveHistoricalFileNameUseCase(stubRepo(new DocumentUnknown()));
+ assertThatNullPointerException()
+ .isThrownBy(() -> useCase.resolveHistoricalFileName(null))
+ .withMessageContaining("fingerprint");
+ }
+
+ @Test
+ void resolveHistoricalFileName_forSuccessRecord_returnsLastTargetFileName() {
+ DocumentRecord record = buildRecord(ProcessingStatus.SUCCESS, "2026-01-01 - Rechnung.pdf");
+ var useCase = new DefaultResolveHistoricalFileNameUseCase(
+ stubRepo(new DocumentTerminalSuccess(record)));
+
+ Optional result = useCase.resolveHistoricalFileName(FP);
+ assertThat(result).contains("2026-01-01 - Rechnung.pdf");
+ }
+
+ @Test
+ void resolveHistoricalFileName_forSuccessRecordWithNullLastTargetFileName_returnsEmpty() {
+ DocumentRecord record = buildRecord(ProcessingStatus.SUCCESS, null);
+ var useCase = new DefaultResolveHistoricalFileNameUseCase(
+ stubRepo(new DocumentTerminalSuccess(record)));
+
+ assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
+ }
+
+ @Test
+ void resolveHistoricalFileName_forUnknownDocument_returnsEmpty() {
+ var useCase = new DefaultResolveHistoricalFileNameUseCase(stubRepo(new DocumentUnknown()));
+
+ assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
+ }
+
+ @Test
+ void resolveHistoricalFileName_forFinalFailure_returnsEmpty() {
+ DocumentRecord record = buildRecord(ProcessingStatus.FAILED_FINAL, null);
+ var useCase = new DefaultResolveHistoricalFileNameUseCase(
+ stubRepo(new DocumentTerminalFinalFailure(record)));
+
+ assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
+ }
+
+ @Test
+ void resolveHistoricalFileName_forProcessableDocument_returnsEmpty() {
+ DocumentRecord record = buildRecord(ProcessingStatus.READY_FOR_AI, null);
+ var useCase = new DefaultResolveHistoricalFileNameUseCase(
+ stubRepo(new DocumentKnownProcessable(record)));
+
+ assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
+ }
+
+ @Test
+ void resolveHistoricalFileName_forPersistenceLookupFailure_returnsEmpty() {
+ var useCase = new DefaultResolveHistoricalFileNameUseCase(
+ stubRepo(new PersistenceLookupTechnicalFailure("DB-Fehler", null)));
+
+ assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
+ }
+
+ @Test
+ void resolveHistoricalFileName_whenRepositoryThrows_returnsEmpty() {
+ DocumentRecordRepository throwingRepo = new DocumentRecordRepository() {
+ @Override
+ public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) {
+ throw new DocumentPersistenceException("Verbindungsfehler", null);
+ }
+ @Override
+ public void create(DocumentRecord record) {}
+ @Override
+ public void update(DocumentRecord record) {}
+ @Override
+ public void deleteByFingerprint(DocumentFingerprint fingerprint) {}
+ };
+
+ var useCase = new DefaultResolveHistoricalFileNameUseCase(throwingRepo);
+
+ assertThat(useCase.resolveHistoricalFileName(FP)).isEmpty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Hilfsmethoden
+ // -------------------------------------------------------------------------
+
+ private static DocumentRecordRepository stubRepo(DocumentRecordLookupResult result) {
+ return new DocumentRecordRepository() {
+ @Override
+ public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) {
+ return result;
+ }
+ @Override
+ public void create(DocumentRecord record) {}
+ @Override
+ public void update(DocumentRecord record) {}
+ @Override
+ public void deleteByFingerprint(DocumentFingerprint fingerprint) {}
+ };
+ }
+
+ private static DocumentRecord buildRecord(ProcessingStatus status, String lastTargetFileName) {
+ return new DocumentRecord(
+ FP,
+ new SourceDocumentLocator("quell/pfad"),
+ "original.pdf",
+ status,
+ FailureCounters.zero(),
+ null,
+ status == ProcessingStatus.SUCCESS ? Instant.now() : null,
+ Instant.now(),
+ Instant.now(),
+ lastTargetFileName != null ? "ziel/ordner" : null,
+ lastTargetFileName);
+ }
+}
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java
index 06f9cbb..5955ace 100644
--- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java
@@ -24,6 +24,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalFileNamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
@@ -58,6 +59,8 @@ import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameUseCase;
+import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalFileNameUseCase;
+import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalFileNameUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
@@ -683,6 +686,7 @@ public class BootstrapRunner {
de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort =
this::resetDocumentStatusForGui;
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
+ GuiHistoricalFileNamePort historicalFileNamePort = this::resolveHistoricalFileNameForGui;
if (configPathOverride.isEmpty()) {
return new GuiStartupContext(
@@ -699,7 +703,8 @@ public class BootstrapRunner {
batchRunLauncher,
miniRunLauncher,
resetPort,
- manualRenamePort);
+ manualRenamePort,
+ historicalFileNamePort);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -721,7 +726,8 @@ public class BootstrapRunner {
batchRunLauncher,
miniRunLauncher,
resetPort,
- manualRenamePort);
+ manualRenamePort,
+ historicalFileNamePort);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -730,7 +736,7 @@ public class BootstrapRunner {
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
- miniRunLauncher, resetPort, manualRenamePort);
+ miniRunLauncher, resetPort, manualRenamePort, historicalFileNamePort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -748,7 +754,8 @@ public class BootstrapRunner {
batchRunLauncher,
miniRunLauncher,
resetPort,
- manualRenamePort);
+ manualRenamePort,
+ historicalFileNamePort);
}
}
@@ -1062,6 +1069,49 @@ public class BootstrapRunner {
}
}
+ /**
+ * Resolves the historical AI-proposed target filename for a document identified by
+ * {@code fingerprint}, using the configuration at {@code configFilePath}.
+ *
+ * Loads the configuration, initialises the schema and delegates to
+ * {@link ResolveHistoricalFileNameUseCase}. Technical errors during loading or querying
+ * are caught and returned as an empty {@link Optional}; they are never propagated to the
+ * caller.
+ *
+ * Runs on the GUI worker thread. Blocking I/O is therefore acceptable.
+ *
+ * @param configFilePath path to the active {@code .properties} file; must not be {@code null}
+ * @param fingerprint content-based document identity; must not be {@code null}
+ * @return the last successfully written target filename, or empty if not available
+ */
+ Optional resolveHistoricalFileNameForGui(
+ Path configFilePath,
+ DocumentFingerprint fingerprint) {
+ Objects.requireNonNull(configFilePath, "configFilePath must not be null");
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
+
+ if (!Files.exists(configFilePath)) {
+ LOG.debug("Historischer Dateiname: Konfigurationsdatei nicht gefunden: {}", configFilePath);
+ return Optional.empty();
+ }
+
+ try {
+ migrateConfigurationIfNeeded(configFilePath);
+ StartConfiguration config = loadAndValidateConfiguration(configFilePath);
+ initializeSchema(config);
+ String jdbcUrl = buildJdbcUrl(config);
+ DocumentRecordRepository documentRecordRepository =
+ new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
+ ResolveHistoricalFileNameUseCase useCase =
+ new DefaultResolveHistoricalFileNameUseCase(documentRecordRepository);
+ return useCase.resolveHistoricalFileName(fingerprint);
+ } catch (Exception e) {
+ LOG.debug("Historischer Dateiname konnte nicht abgefragt werden für {}: {}",
+ fingerprint.sha256Hex(), e.getMessage());
+ return Optional.empty();
+ }
+ }
+
/**
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
* recorded as a failure with the given error message.