Fix #30: Detailbereich bei SKIPPED-Zeilen mit historischen Informationen befüllen

- Teile DocumentCompletionStatus.SKIPPED in SKIPPED_ALREADY_PROCESSED und
  SKIPPED_FINAL_FAILURE auf, um den Skip-Grund unterscheidbar zu machen
- Führe neuen Typ HistoricalDocumentContext ein (lastTargetFileName,
  lastSuccessInstant, lastFailureInstant, wasEverSuccessful)
- Führe ResolveHistoricalDocumentContextUseCase und
  DefaultResolveHistoricalDocumentContextUseCase ein
- Ersetze GuiHistoricalFileNamePort durch GuiHistoricalDocumentContextPort
- Lade historischen Kontext für übersprungene Zeilen im Coordinator-Worker-Thread
- Zeige im Detailbereich je nach Skip-Grund:
  SKIPPED_ALREADY_PROCESSED: "Bereits erfolgreich verarbeitet am [Datum]. Zieldatei: [Name]."
  SKIPPED_FINAL_FAILURE: "Endgültig fehlgeschlagen am [Datum]. Erneute Verarbeitung nur nach Reset möglich."
- Passe alle betroffenen Tests an

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:00:27 +02:00
parent 1db6e27be8
commit 3f5602de01
22 changed files with 605 additions and 103 deletions
@@ -17,7 +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.GuiHistoricalDocumentContextPort;
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;
@@ -373,10 +373,10 @@ 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.
* Port used by the processing-run coordinator to resolve the historical processing context
* for skipped documents. Supplied by Bootstrap via the startup context.
*/
private final GuiHistoricalFileNamePort historicalFileNamePort;
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
/**
* Second main tab of the window that drives the live processing-run view. Created
@@ -453,7 +453,7 @@ public final class GuiConfigurationEditorWorkspace {
this.miniRunLauncher = effectiveContext.miniRunLauncher();
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
this.historicalFileNamePort = effectiveContext.historicalFileNamePort();
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
() -> this.miniRunLauncher,
@@ -462,7 +462,7 @@ public final class GuiConfigurationEditorWorkspace {
this::isSavedConfigurationReady,
this::applyBatchRunLockState,
() -> this.manualFileRenamePort,
() -> this.historicalFileNamePort,
() -> this.historicalDocumentContextPort,
this::editorSourceFolder,
this::editorTargetFolder);
@@ -6,7 +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.GuiHistoricalDocumentContextPort;
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;
@@ -42,8 +42,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* 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, and
* the {@link GuiHistoricalFileNamePort} used to retrieve the historical AI-proposed filename
* for documents that were skipped in the current run.
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
* context for documents that were skipped in the current run.
* <p>
* 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.
@@ -63,7 +63,7 @@ public record GuiStartupContext(
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiHistoricalFileNamePort historicalFileNamePort) {
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
/**
* Creates a fully wired startup context.
@@ -85,8 +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}
* @param historicalDocumentContextPort bridge that resolves the historical processing context
* for skipped documents; must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -115,8 +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");
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
"historicalDocumentContextPort must not be null");
}
/**
@@ -157,7 +157,7 @@ public record GuiStartupContext(
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
noOpHistoricalFileNamePort());
noOpHistoricalDocumentContextPort());
}
/**
@@ -192,7 +192,7 @@ public record GuiStartupContext(
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
noOpHistoricalFileNamePort());
noOpHistoricalDocumentContextPort());
}
/**
@@ -227,7 +227,7 @@ public record GuiStartupContext(
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
rejectingManualFileRenamePort(), noOpHistoricalFileNamePort());
rejectingManualFileRenamePort(), noOpHistoricalDocumentContextPort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -256,7 +256,7 @@ public record GuiStartupContext(
"Kein Umbennennungs-Port in diesem Startkontext verfügbar.");
}
private static GuiHistoricalFileNamePort noOpHistoricalFileNamePort() {
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
return (configPath, fingerprint) -> java.util.Optional.empty();
}
@@ -334,6 +334,6 @@ public record GuiStartupContext(
rejectingMiniRunLauncher(),
rejectingResetPort(),
rejectingManualFileRenamePort(),
noOpHistoricalFileNamePort());
noOpHistoricalDocumentContextPort());
}
}
@@ -144,7 +144,7 @@ public final class FileNameEditorPane {
* <p>
* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet,
* der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}.
* Bei nicht editierbaren Status (FAILED_*, SKIPPED, reset-pending, kein SUCCESS)
* Bei nicht editierbaren Status (FAILED_*, SKIPPED_*, reset-pending, kein SUCCESS)
* wird das Feld deaktiviert.
*
* @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()}
@@ -18,6 +18,7 @@ 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.HistoricalDocumentContext;
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;
@@ -113,7 +114,7 @@ public final class GuiBatchRunCoordinator {
private final Function<Runnable, Thread> threadFactory;
private final Consumer<Runnable> fxDispatcher;
private final Listener listener;
private final GuiHistoricalFileNamePort historicalFileNamePort;
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
@@ -163,16 +164,16 @@ public final class GuiBatchRunCoordinator {
* @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
* @param historicalDocumentContextPort 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) {
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
this(launcher, miniRunLauncher, resetPort,
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalFileNamePort);
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort);
}
/**
@@ -200,7 +201,7 @@ public final class GuiBatchRunCoordinator {
Consumer<Runnable> fxDispatcher,
Listener listener) {
this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
noOpHistoricalFileNamePort());
noOpHistoricalDocumentContextPort());
}
/**
@@ -218,7 +219,7 @@ public final class GuiBatchRunCoordinator {
* @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
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
* skipped documents; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
@@ -227,15 +228,15 @@ public final class GuiBatchRunCoordinator {
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener,
GuiHistoricalFileNamePort historicalFileNamePort) {
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
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");
this.historicalDocumentContextPort = Objects.requireNonNull(
historicalDocumentContextPort, "historicalDocumentContextPort must not be null");
}
/**
@@ -555,10 +556,12 @@ public final class GuiBatchRunCoordinator {
/**
* Wandelt ein {@link DocumentCompletionEvent} in eine {@link GuiBatchRunResultRow} um.
* <p>
* 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.
* Für übersprungene Dokumente ({@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}
* und {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}) wird der historische
* Verarbeitungskontext über den {@link GuiHistoricalDocumentContextPort} nachgeladen.
* Für SKIPPED_ALREADY_PROCESSED wird der letzte Zieldateiname aus dem Kontext als
* {@code finalName} übernommen. Schlägt die Abfrage fehl, bleibt der Kontext 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
@@ -567,16 +570,6 @@ public final class GuiBatchRunCoordinator {
private GuiBatchRunResultRow toRow(DocumentCompletionEvent event, Path configFilePath) {
Optional<String> 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<LocalDate> date = event.resolvedDate() == null
? Optional.empty() : Optional.of(event.resolvedDate());
Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
@@ -584,18 +577,41 @@ public final class GuiBatchRunCoordinator {
Optional<String> failureMessage = event.failureMessage() == null || event.failureMessage().isBlank()
? Optional.empty() : Optional.of(event.failureMessage());
Duration duration = event.processingDuration();
// Historischen Kontext für übersprungene Dokumente nachladen
boolean isSkipped = event.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED
|| event.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE;
Optional<HistoricalDocumentContext> historicalContext = Optional.empty();
if (isSkipped) {
try {
historicalContext = historicalDocumentContextPort
.resolveHistoricalDocumentContext(configFilePath, event.fingerprint());
} catch (Exception e) {
LOG.warn("Historischer Kontext konnte nicht abgefragt werden für {}: {}",
event.originalFileName(), e.getMessage(), e);
}
// Zieldateiname für SKIPPED_ALREADY_PROCESSED aus Kontext übernehmen
if (finalName.isEmpty()) {
finalName = historicalContext
.flatMap(HistoricalDocumentContext::lastTargetFileName);
}
}
return new GuiBatchRunResultRow(
event.originalFileName(),
event.fingerprint(),
event.status(),
finalName,
Optional.empty(),
date,
reasoning,
failureMessage,
duration);
duration,
false,
historicalContext);
}
private static GuiHistoricalFileNamePort noOpHistoricalFileNamePort() {
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
return (configPath, fingerprint) -> Optional.empty();
}
@@ -6,6 +6,7 @@ import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
@@ -44,6 +45,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* never {@code null} and never negative
* @param resetPending {@code true} when the document's persistence status has been
* reset and is awaiting the next processing run
* @param historicalContext historischer Verarbeitungskontext für übersprungene Dokumente;
* leer bei nicht-übersprungenen Zeilen
*/
public record GuiBatchRunResultRow(
String originalFileName,
@@ -55,7 +58,8 @@ public record GuiBatchRunResultRow(
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration,
boolean resetPending) {
boolean resetPending,
Optional<HistoricalDocumentContext> historicalContext) {
/**
* Label shown in the status column when a document's persistence status has been
@@ -93,11 +97,12 @@ public record GuiBatchRunResultRow(
if (processingDuration.isNegative()) {
throw new IllegalArgumentException("processingDuration must not be negative");
}
historicalContext = historicalContext == null ? Optional.empty() : historicalContext;
}
/**
* Bequem-Konstruktor für Zeilen, die weder einen manuell korrigierten Dateinamen
* tragen noch im reset-pending-Zustand stehen.
* tragen noch im reset-pending-Zustand stehen und keinen historischen Kontext haben.
*
* @param originalFileName the source filename; never {@code null} or blank
* @param fingerprint the content-based document identity; never {@code null}
@@ -122,12 +127,13 @@ public record GuiBatchRunResultRow(
Optional<String> aiFailureMessage,
Duration processingDuration) {
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, false);
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, false,
Optional.empty());
}
/**
* Bequem-Konstruktor mit explizitem {@code resetPending}-Flag, aber ohne manuell
* korrigierten Dateinamen.
* korrigierten Dateinamen und ohne historischen Kontext.
*
* @param originalFileName the source filename; never {@code null} or blank
* @param fingerprint the content-based document identity; never {@code null}
@@ -154,7 +160,8 @@ public record GuiBatchRunResultRow(
Duration processingDuration,
boolean resetPending) {
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, resetPending);
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, resetPending,
Optional.empty());
}
/**
@@ -178,8 +185,10 @@ public record GuiBatchRunResultRow(
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
true);
true,
Optional.empty());
}
/**
@@ -199,7 +208,8 @@ public record GuiBatchRunResultRow(
case SUCCESS -> "\u2714"; // ✔ HEAVY CHECK MARK
case FAILED_RETRYABLE -> "\u26A0"; // ⚠ WARNING SIGN
case FAILED_PERMANENT -> "\u2718"; // ✘ HEAVY BALLOT X
case SKIPPED -> "\u25BA"; // ► BLACK RIGHT-POINTING POINTER
case SKIPPED_ALREADY_PROCESSED,
SKIPPED_FINAL_FAILURE -> "\u25BA"; // ► BLACK RIGHT-POINTING POINTER
};
}
@@ -219,7 +229,8 @@ public record GuiBatchRunResultRow(
case SUCCESS -> "Erfolgreich";
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
case FAILED_PERMANENT -> "Fehlgeschlagen (permanent)";
case SKIPPED -> "Übersprungen";
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen (bereits verarbeitet)";
case SKIPPED_FINAL_FAILURE -> "Übersprungen (endgültig fehlgeschlagen)";
};
}
@@ -2,7 +2,9 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.time.Duration;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -193,7 +195,7 @@ public final class GuiBatchRunTab {
private final Runnable onRunStateChanged;
private final GuiBatchRunCoordinator coordinator;
private final Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier;
private final Supplier<GuiHistoricalFileNamePort> historicalFileNamePortSupplier;
private final Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier;
private final Supplier<Optional<Path>> sourceFolderSupplier;
private final Supplier<Optional<String>> targetFolderSupplier;
@@ -232,8 +234,8 @@ public final class GuiBatchRunTab {
* null sein
* @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 historicalDocumentContextPortSupplier Supplier für den historischen Kontext-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
@@ -246,7 +248,7 @@ public final class GuiBatchRunTab {
BooleanSupplier savedConfigurationReadyCheck,
Runnable onRunStateChanged,
Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
Supplier<GuiHistoricalFileNamePort> historicalFileNamePortSupplier,
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
Supplier<Optional<Path>> sourceFolderSupplier,
Supplier<Optional<String>> targetFolderSupplier) {
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
@@ -258,8 +260,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.historicalDocumentContextPortSupplier = Objects.requireNonNull(
historicalDocumentContextPortSupplier, "historicalDocumentContextPortSupplier must not be null");
this.sourceFolderSupplier = Objects.requireNonNull(
sourceFolderSupplier, "sourceFolderSupplier must not be null");
this.targetFolderSupplier = Objects.requireNonNull(
@@ -273,7 +275,7 @@ public final class GuiBatchRunTab {
(configPath, fingerprints) ->
resetPortSupplier.get().reset(configPath, fingerprints),
new CoordinatorListener(),
historicalFileNamePortSupplier.get());
historicalDocumentContextPortSupplier.get());
this.tab.setClosable(false);
this.tab.setContent(buildContent());
@@ -799,7 +801,8 @@ public final class GuiBatchRunTab {
row.aiReasoning(),
row.aiFailureMessage(),
row.processingDuration(),
row.resetPending());
row.resetPending(),
row.historicalContext());
currentlySelectedRow = updatedRow;
// Dirty-State vor dem Zeilen-Upsert zurücksetzen, damit das folgende
// resultItems.set() keinen Verwerfen-Dialog über den selectedItemProperty-Listener
@@ -1216,7 +1219,7 @@ public final class GuiBatchRunTab {
case SUCCESS -> "#2e7d32";
case FAILED_RETRYABLE -> "#e65100";
case FAILED_PERMANENT -> "#c62828";
case SKIPPED -> "#757575";
case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> "#757575";
};
}
@@ -1228,6 +1231,9 @@ public final class GuiBatchRunTab {
return String.format("%.1f s", seconds);
}
private static final DateTimeFormatter DETAIL_DATE_FORMAT =
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale.GERMANY);
private static String buildDetailText(GuiBatchRunResultRow row) {
StringBuilder builder = new StringBuilder();
builder.append("Originaldateiname: ").append(row.originalFileName()).append('\n');
@@ -1235,6 +1241,34 @@ public final class GuiBatchRunTab {
builder.append('\n').append(GuiBatchRunResultRow.RESET_PENDING_LABEL);
return builder.toString();
}
if (row.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED) {
builder.append('\n');
row.historicalContext().ifPresentOrElse(ctx -> {
ctx.lastSuccessInstant().ifPresentOrElse(
instant -> builder.append("Bereits erfolgreich verarbeitet am ")
.append(DETAIL_DATE_FORMAT.format(
instant.atZone(ZoneId.systemDefault())))
.append('.'),
() -> builder.append("Bereits erfolgreich verarbeitet."));
ctx.lastTargetFileName().ifPresent(name ->
builder.append('\n').append("Zieldatei: ").append(name).append('.'));
}, () -> builder.append("Bereits erfolgreich verarbeitet."));
return builder.toString();
}
if (row.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE) {
builder.append('\n');
row.historicalContext().ifPresentOrElse(ctx ->
ctx.lastFailureInstant().ifPresentOrElse(
instant -> builder.append("Endg\u00fcltig fehlgeschlagen am ")
.append(DETAIL_DATE_FORMAT.format(
instant.atZone(ZoneId.systemDefault())))
.append(". Erneute Verarbeitung nur nach Reset m\u00f6glich."),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich.")),
() -> builder.append(
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
return builder.toString();
}
row.effectiveFileName()
.ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
row.resolvedDate()
@@ -1311,7 +1345,7 @@ public final class GuiBatchRunTab {
switch (row.status()) {
case SUCCESS -> successCount++;
case FAILED_RETRYABLE, FAILED_PERMANENT -> failedCount++;
case SKIPPED -> skippedCount++;
case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> skippedCount++;
default -> throw new IllegalStateException(
"Unerwarteter Status: " + row.status());
}
@@ -0,0 +1,42 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* GUI-interner Port zum Abfragen des historischen Verarbeitungskontexts einer Quelldatei.
* <p>
* Wird im Verarbeitungslauf-Tab genutzt, um für übersprungene Dokumente
* ({@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}
* und
* {@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED_FINAL_FAILURE})
* den historischen Kontext nachzuschlagen. Der Kontext wird im Detailbereich des
* Verarbeitungslauf-Tabs angezeigt.
* <p>
* 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 werden intern
* abgefangen und als leeres {@link Optional} zurückgegeben.
* <p>
* Die Implementierung läuft auf dem Worker-Thread des {@link GuiBatchRunCoordinator}
* und darf blockieren.
*/
@FunctionalInterface
public interface GuiHistoricalDocumentContextPort {
/**
* Gibt den historischen Verarbeitungskontext für das durch {@code fingerprint}
* identifizierte Dokument zurück, oder ein leeres {@link Optional}, wenn kein
* Kontext 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 historischer Kontext des Dokuments, oder leer wenn nicht verfügbar
*/
Optional<HistoricalDocumentContext> resolveHistoricalDocumentContext(
Path configFilePath, DocumentFingerprint fingerprint);
}
@@ -321,7 +321,7 @@ class FileNameEditorPaneTest {
Optional.of("2026-01-01 - KI-Vorschlag.pdf"),
Optional.of("2026-01-01 - Manuell.pdf"),
Optional.empty(), Optional.empty(), Optional.empty(),
Duration.ofMillis(1), false);
Duration.ofMillis(1), false, Optional.empty());
pane.loadSelection(row, "C:\\target");
// lastSavedName = "2026-01-01 - Manuell" (effectiveFileName)
assertEquals("2026-01-01 - Manuell", pane.textField().getText());
@@ -118,7 +118,7 @@ class GuiBatchRunCoordinatorTest {
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
observer.onRunStarted(new RunId("run-skip"), 1);
observer.onDocumentCompleted(new DocumentCompletionEvent(
"c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED,
"c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
null, null, null, null, Duration.ofMillis(5)));
observer.onRunEnded(new RunSummary(0, 0, 1));
return GuiBatchRunLaunchOutcome.completed();
@@ -131,7 +131,7 @@ class GuiBatchRunCoordinatorTest {
assertEquals(List.of(
"started:1",
"row:SKIPPED:c.pdf",
"row:SKIPPED_ALREADY_PROCESSED:c.pdf",
"ended:started=true,completed=true,summary=0/0/1"), events);
assertFalse(coordinator.isRunning());
}
@@ -290,7 +290,7 @@ class GuiBatchRunCoordinatorTest {
assertEquals("\u2714", row(DocumentCompletionStatus.SUCCESS).statusIcon());
assertEquals("\u26A0", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
assertEquals("\u2718", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED).statusIcon());
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
}
@Test
@@ -109,8 +109,13 @@ class GuiBatchRunResultRowTest {
}
@Test
void statusIcon_skipped_isPointer() {
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED).statusIcon());
void statusIcon_skippedAlreadyProcessed_isPointer() {
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
}
@Test
void statusIcon_skippedFinalFailure_isPointer() {
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon());
}
// -------------------------------------------------------------------------
@@ -84,7 +84,7 @@ class GuiBatchRunTabSelectionSmokeTest {
runOnFx(() -> {
GuiBatchRunTab tab = makeTab();
tab.upsertResultRowByFingerprint(row("a.pdf", FP1, DocumentCompletionStatus.SUCCESS));
tab.upsertResultRowByFingerprint(row("b.pdf", FP2, DocumentCompletionStatus.SKIPPED));
tab.upsertResultRowByFingerprint(row("b.pdf", FP2, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
tab.upsertResultRowByFingerprint(row("c.pdf", FP3, DocumentCompletionStatus.FAILED_PERMANENT));
assertEquals(3, tab.resultTable().getItems().size());
});
@@ -107,7 +107,7 @@ class GuiBatchRunTabSmokeTest {
"b.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_RETRYABLE,
null, null, null, null, Duration.ofMillis(10)));
observer.onDocumentCompleted(new DocumentCompletionEvent(
"c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED,
"c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
null, null, null, null, Duration.ofMillis(5)));
observer.onRunEnded(new RunSummary(1, 1, 1));
return GuiBatchRunLaunchOutcome.completed();
@@ -142,7 +142,7 @@ class GuiBatchRunTabSmokeTest {
// SKIPPED row must carry the ► icon, not ✘.
GuiBatchRunResultRow skippedRow = tab().resultTable().getItems().get(2);
assertEquals(DocumentCompletionStatus.SKIPPED, skippedRow.status());
assertEquals(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, skippedRow.status());
assertEquals("\u25BA", skippedRow.statusIcon());
});
}