diff --git a/docs/betrieb.md b/docs/betrieb.md index 8327e53..3076eb6 100644 --- a/docs/betrieb.md +++ b/docs/betrieb.md @@ -544,6 +544,48 @@ Auf Unix-Systemen (headless CI): --- +## GUI: Selektive Wiederverarbeitung und Status-Reset + +Die GUI ermöglicht nach Abschluss eines Verarbeitungslaufs zwei zusätzliche Aktionen auf der Ergebnisliste: + +### Selektion in der Ergebnisliste + +Die Ergebnisliste enthält eine **Checkbox pro Zeile** sowie eine **Master-Checkbox** zum Auswählen aller Einträge. +- Auswahl erfolgt wie im Windows Explorer mit **Shift/Strg-Mehrfachselektion** +- Alle vier Statustypen sind selektierbar: erfolgreich, retryable, permanent fehlgeschlagen, übersprungen +- Während eines Laufs ist die Selektion **gesperrt** + +### Button „Erneut verarbeiten" + +**Aktion:** DB-Status zurücksetzen + sofortiger Mini-Lauf nur für ausgewählte Dateien. + +- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist +- Der Mini-Lauf arbeitet auf einem Snapshot der beim Klick ausgewählten Einträge +- Nicht ausgewählte Einträge bleiben unverändert in der Liste +- Verhalten identisch zu regulärem Lauf (gleiche Anwendungslogik, nur eingeschränkte Dateimenge) + +**Besonderheit bei identischem Zieldateinamen:** Verarbeitet der KI-Provider wieder denselben Dateinamen wie ein vorangegangener erfolgreicher Lauf, erhält der Eintrag **Status erfolgreich** – es wird keine erneute Kopie erzeugt, kein Fehler. + +**Fehlende Quelldatei:** Ist die Datei zum Zeitpunkt des Mini-Laufs nicht mehr vorhanden, erhält der Eintrag **Status permanent fehlgeschlagen** mit Meldung „Quelldatei nicht gefunden". + +### Button „Status zurücksetzen" + +**Aktion:** Nur DB-Status zurücksetzen, keine sofortige Verarbeitung. + +- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist +- Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt – wartet auf nächsten Lauf"** +- Beim nächsten regulären Lauf werden zurückgesetzte Dateien automatisch mitgenommen +- **Best-effort-Reset:** Erfolgreiche und fehlgeschlagene Resets werden pro Eintrag einzeln durchgeführt; Zusammenfassung zeigt Erfolge und Fehler + +### Verhalten während eines Mini-Laufs + +- Der **Abbrechen-Button** gilt auch für Mini-Läufe (Soft-Stop) +- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt** +- Nach Soft-Stop: bereits verarbeitete Einträge behalten neuen Status, noch nicht gestartete zurückgesetzte Einträge warten auf nächsten regulären Lauf +- Fortschrittsbalken zeigt Fortschritt für die ausgewählte Dateimenge + +--- + ## Weitere Dokumentation Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md) beschrieben. diff --git a/docs/gui-bedienanleitung.md b/docs/gui-bedienanleitung.md index 38510da..a8df0a5 100644 --- a/docs/gui-bedienanleitung.md +++ b/docs/gui-bedienanleitung.md @@ -504,6 +504,80 @@ Hinweisdialog mit zwei Optionen: --- +## 13a. Selektion, Wiederverarbeitung und Status-Reset (V2.8) + +Nach Abschluss eines Verarbeitungslaufs können einzelne oder mehrere Dateien aus der +Ergebnisliste gezielt erneut verarbeitet oder deren Status zurückgesetzt werden. + +### Selektion in der Ergebnisliste + +- Jede Zeile hat eine **Checkbox** am linken Rand +- Zusätzlich eine **Master-Checkbox** oberhalb der Liste zum Auswählen/Abwählen aller Einträge +- **Zeilenklick** (auf Text/Status-Icon) repräsentiert dieselbe Selektionsmenge wie die Checkbox +- **Shift/Strg-Mehrfachselektion** funktioniert wie im Windows Explorer + - Shift+Klick: Bereich vom letzten zur aktuellen Zeile + - Strg+Klick: einzelne Zeilen hinzufügen/entfernen +- Alle vier Statustypen sind selektierbar: ✅ erfolgreich, ⚠️ retryable, ❌ permanent, ⏭️ übersprungen +- Die Selektion bleibt nach Aktionen erhalten, bis ein neuer Lauf gestartet wird + +### Button „Erneut verarbeiten" + +**Wann nutzen:** Der KI-Prompt wurde geändert, das Modell gewechselt oder die Verarbeitung einer Datei +muss aus anderen Gründen wiederholt werden – und das Ergebnis soll sofort verfügbar sein. + +**Was passiert:** +1. Wird ein Button-Klick ausgelöst, wird die aktuelle Selektion als **Snapshot** erfasst +2. Der DB-Status aller selektierten Einträge wird zurückgesetzt +3. Ein **Mini-Lauf** startet sofort und verarbeitet nur diese Dateien +4. Unselektierte Einträge bleiben unverändert in der Liste +5. Die Mini-Lauf-Ergebnisse werden live in den selektierten Zeilen aktualisiert + +**Besonderheiten:** +- Verarbeitet die KI wieder denselben Dateinamen wie der vorherige erfolgreiche Lauf, + erfolgt **keine erneute Kopie** – der Eintrag erhält Status ✅ erfolgreich +- Ist die Quelldatei nicht mehr vorhanden, erhält der Eintrag Status ❌ permanent fehlgeschlagen + mit Meldung „Quelldatei nicht gefunden" + +**Button-Status:** +- **Aktiv:** kein Lauf aktiv UND mindestens 1 Eintrag selektiert +- **Inaktiv:** Lauf läuft ODER keine Selektion + +### Button „Status zurücksetzen" + +**Wann nutzen:** Eine Datei soll später erneut verarbeitet werden, aber nicht sofort – z. B. nach +Behebung eines externen Fehlers oder planmäßig im nächsten regulären Lauf. + +**Was passiert:** +1. Der DB-Status aller selektierten Einträge wird zurückgesetzt +2. Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt – wartet auf nächsten Lauf"** +3. **Kein sofortiger Mini-Lauf** +4. Beim nächsten regulären Lauf werden diese Dateien automatisch mitgenommen + +**Fehlerbehandlung (Best-effort):** +- Resets werden pro Eintrag einzeln durchgeführt +- Erfolgreiche und fehlgeschlagene Resets werden separat gezählt +- Zusammenfassung im Meldungsbereich zeigt: + - Anzahl ausgewählter Einträge + - Anzahl erfolgreich zurückgesetzt + - Anzahl fehlgeschlagen + betroffene Dateinamen + +**Button-Status:** +- **Aktiv:** kein Lauf aktiv UND mindestens 1 Eintrag selektiert +- **Inaktiv:** Lauf läuft ODER keine Selektion + +### Verhalten während eines Mini-Laufs + +- Der **Abbrechen-Button** löst einen Soft-Stop auch für Mini-Läufe aus: + - bereits verarbeitete Einträge behalten ihren neuen Endstatus + - noch nicht gestartete, aber bereits zurückgesetzte Einträge erhalten Status + „Zurückgesetzt – wartet auf nächsten Lauf" und werden beim nächsten regulären Lauf mitgenommen +- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt** +- Der **Fortschrittsbalken** zeigt den Fortschritt für die ausgewählte Dateimenge + (Nenner = Anzahl selektierter Dateien) +- Beide Buttons „Erneut verarbeiten" und „Status zurücksetzen" sind **deaktiviert** + +--- + ## 14. Bekannte Einschränkungen V2.x | Einschränkung | Erläuterung | 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 af6a888..e79c2a5 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 @@ -16,6 +16,8 @@ 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.GuiMiniRunLauncher; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiApiKeyMerger; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiChangeState; @@ -344,11 +346,24 @@ public final class GuiConfigurationEditorWorkspace { private final ApiKeyResolutionPort apiKeyResolutionPort; /** - * Launcher used by the processing-run tab to execute a batch run against the saved - * configuration file. Supplied by Bootstrap via the startup context. + * Launcher used by the processing-run tab to execute a regular batch run against the + * saved configuration file. Supplied by Bootstrap via the startup context. */ private final GuiBatchRunLauncher batchRunLauncher; + /** + * Launcher used by the processing-run tab to execute a targeted mini-run for a + * selected set of documents. Supplied by Bootstrap via the startup context. + */ + private final GuiMiniRunLauncher miniRunLauncher; + + /** + * Port used by the processing-run tab to reset the persistence status of selected + * documents without triggering a reprocessing run. Supplied by Bootstrap via the + * startup context. + */ + private final GuiResetDocumentStatusPort resetDocumentStatusPort; + /** * 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 @@ -421,8 +436,12 @@ public final class GuiConfigurationEditorWorkspace { triggerLabel -> showUnsavedChangesDialog(triggerLabel)); this.batchRunLauncher = effectiveContext.batchRunLauncher(); + this.miniRunLauncher = effectiveContext.miniRunLauncher(); + this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort(); this.batchRunTab = new GuiBatchRunTab( () -> this.batchRunLauncher, + () -> this.miniRunLauncher, + () -> this.resetDocumentStatusPort, this::loadedConfigurationPath, this::isSavedConfigurationReady, this::applyBatchRunLockState); 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 c3954a8..146dea7 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 @@ -2,11 +2,15 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui; import java.util.Objects; import java.util.Optional; +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.GuiMiniRunLauncher; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort; 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; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort; @@ -15,6 +19,7 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheck import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService; import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort; import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; /** * Immutable startup data for the GUI adapter. @@ -26,9 +31,12 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.Technical * API key provenance from environment variables, the {@link ProviderTechnicalTestService} * used to execute provider-specific technical checks, the {@link PathCheckPort} * used to verify filesystem path accessibility for configuration values, the - * {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, and the + * {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, the * {@link CorrectionExecutionService} used to execute corrective actions after a - * technical test run has been confirmed by the user. + * technical test run has been confirmed by the user, the {@link GuiBatchRunLauncher} used + * to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted + * mini-runs for selected documents, and the {@link GuiResetDocumentStatusPort} used to + * reset the persistence status of selected documents. *
* 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.
@@ -44,10 +52,12 @@ public record GuiStartupContext(
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
- GuiBatchRunLauncher batchRunLauncher) {
+ GuiBatchRunLauncher batchRunLauncher,
+ GuiMiniRunLauncher miniRunLauncher,
+ GuiResetDocumentStatusPort resetDocumentStatusPort) {
/**
- * Creates a startup context.
+ * Creates a fully wired startup context.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
@@ -59,9 +69,11 @@ public record GuiStartupContext(
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
- * @param batchRunLauncher bridge that executes a batch run against a stored
- * configuration path for the processing-run tab;
- * must not be {@code null}
+ * @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
+ * @param miniRunLauncher bridge that executes a targeted mini-run for selected
+ * documents; must not be {@code null}
+ * @param resetDocumentStatusPort bridge that resets the persistence status of selected
+ * documents; must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -84,15 +96,51 @@ public record GuiStartupContext(
"correctionExecutionService must not be null");
batchRunLauncher = Objects.requireNonNull(batchRunLauncher,
"batchRunLauncher must not be null");
+ miniRunLauncher = Objects.requireNonNull(miniRunLauncher,
+ "miniRunLauncher must not be null");
+ resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
+ "resetDocumentStatusPort must not be null");
}
/**
- * Backward-compatible constructor that fills the processing-run launcher with a
- * no-op implementation.
+ * Backward-compatible constructor that fills the mini-run launcher and reset port
+ * with no-op implementations.
+ *
+ * @param initialState initial editor state; must not be {@code null}
+ * @param startupNotice optional startup notice; {@code null} becomes empty
+ * @param configurationFileLoader file-loading callback; must not be {@code null}
+ * @param configurationFileWriter file-writing callback; must not be {@code null}
+ * @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
+ * @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
+ * @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
+ * @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
+ * @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
+ * @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
+ * @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
+ */
+ public GuiStartupContext(
+ GuiConfigurationEditorState initialState,
+ Optional
* Preserves existing callers that were written before the processing-run tab was added.
- * The no-op launcher rejects every start request with a clear German message so the
- * UI never enters an unsafe state in legacy test wiring.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
@@ -119,7 +167,7 @@ public record GuiStartupContext(
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService,
- rejectingBatchRunLauncher());
+ rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -127,20 +175,24 @@ public record GuiStartupContext(
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
}
+ private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
+ return (configPath, filter, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
+ "Kein Mini-Run-Launcher in diesem Startkontext verfügbar.");
+ }
+
+ private static GuiResetDocumentStatusPort rejectingResetPort() {
+ return (configPath, fingerprints) -> {
+ java.util.Map
- * The no-op model catalogue port always returns {@code IncompleteConfiguration}.
- * The no-op API key resolution port always returns {@code ABSENT}.
- * The no-op provider technical test service uses the no-op ports above.
- * The no-op path check port always returns {@code false} for all checks.
- * The no-op technical test orchestrator returns a report where all checkpoints are
- * {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult.NotApplicable}.
- * The no-op correction execution service uses a no-op {@link ResourceCreationPort} that always
- * returns {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted}.
* This is safe for environments where no Bootstrap wiring is present, such as isolated
* GUI tests.
*
@@ -208,6 +260,8 @@ public record GuiStartupContext(
noOpPathCheckPort,
noOpOrchestrator,
noOpCorrectionService,
- noOpBatchRunLauncher);
+ noOpBatchRunLauncher,
+ rejectingMiniRunLauncher(),
+ rejectingResetPort());
}
}
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 6793b90..a0bfd4e 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
@@ -5,6 +5,7 @@ import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
@@ -16,12 +17,15 @@ 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.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import javafx.application.Platform;
/**
- * Coordinates a single batch run triggered from the JavaFX GUI.
+ * Coordinates a single batch run (regular or targeted mini-run) triggered from the
+ * JavaFX GUI, and optional reset-only operations on selected document fingerprints.
*
* The coordinator owns the background worker thread that executes the run, maintains the
* cancellation flag, and translates the
@@ -30,7 +34,7 @@ import javafx.application.Platform;
*
*
+ * The default implementation does nothing so existing {@link Listener}
+ * implementations need not override this method until they need reset
+ * notifications.
+ *
+ * @param result the full outcome of the reset operation; never {@code null}
+ */
+ default void onResetCompleted(ResetDocumentStatusResult result) {
+ // no-op default
+ }
}
private final GuiBatchRunLauncher launcher;
+ private final GuiMiniRunLauncher miniRunLauncher;
+ private final GuiResetDocumentStatusPort resetPort;
private final Function
+ * Mini-run and reset capabilities are unavailable; all such requests will return
+ * {@code false}.
*
* @param launcher bridge to Bootstrap used to execute the batch; must not be null
* @param listener GUI listener invoked on the FX thread; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, Listener listener) {
- this(launcher, defaultThreadFactory(), defaultFxDispatcher(), listener);
+ this(launcher,
+ rejectingMiniRunLauncher(),
+ rejectingResetPort(),
+ defaultThreadFactory(),
+ defaultFxDispatcher(),
+ listener);
+ }
+
+ /**
+ * Creates the coordinator with all ports and 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
+ */
+ public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
+ GuiMiniRunLauncher miniRunLauncher,
+ GuiResetDocumentStatusPort resetPort,
+ Listener listener) {
+ this(launcher, miniRunLauncher, resetPort,
+ defaultThreadFactory(), defaultFxDispatcher(), listener);
}
/**
@@ -112,6 +160,34 @@ public final class GuiBatchRunCoordinator {
* thread UI callbacks run on, without depending on an actual JavaFX runtime being
* initialised.
*
+ * @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
+ */
+ public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
+ GuiMiniRunLauncher miniRunLauncher,
+ GuiResetDocumentStatusPort resetPort,
+ Function
* Immediately returns once the worker thread has been started. All further progress
* is communicated through the configured {@link Listener} on the JavaFX Application
@@ -160,19 +238,71 @@ public final class GuiBatchRunCoordinator {
}
cancellationRequested.set(false);
Runnable task = () -> executeRun(configFilePath);
- Thread worker = threadFactory.apply(task);
- Objects.requireNonNull(worker, "threadFactory must not return null");
- activeWorker.set(worker);
- worker.start();
- return true;
+ return startWorker(task);
}
/**
- * Requests soft-stop cancellation of the currently running batch.
+ * Starts a targeted mini-run for the supplied fingerprint filter.
+ *
+ * The worker thread first delegates to the {@link GuiMiniRunLauncher} which applies
+ * the full processing pipeline to only the specified documents. Progress callbacks
+ * are forwarded to the {@link Listener} on the JavaFX Application Thread in the same
+ * way as for a regular run.
+ *
+ * @param configFilePath the configuration file; must not be {@code null}
+ * @param fingerprintFilter the set of document fingerprints to process; must not be
+ * {@code null}
+ * @return {@code true} when a new worker thread was started, {@code false} when a run
+ * was already in progress
+ * @throws NullPointerException if any argument is {@code null}
+ */
+ public boolean startMiniRun(Path configFilePath,
+ Set
+ * The worker thread calls the {@link GuiResetDocumentStatusPort} to delete all
+ * persistence data for the specified fingerprints. No reprocessing run is triggered.
+ * On completion the {@link Listener#onResetCompleted(ResetDocumentStatusResult)} callback
+ * is invoked on the JavaFX Application Thread.
+ *
+ * @param configFilePath the configuration file that identifies the database; must not
+ * be {@code null}
+ * @param fingerprints the set of document fingerprints to reset; must not be
+ * {@code null}
+ * @return {@code true} when a new worker thread was started, {@code false} when a run
+ * was already in progress
+ * @throws NullPointerException if any argument is {@code null}
+ */
+ public boolean startReset(Path configFilePath, Set
* The flag is honoured between candidates — the candidate that is currently being
* processed is always completed in full and persisted before the run ends. Calling
- * this method when no run is active has no effect.
+ * this method when no run is active has no effect. Reset operations ignore this flag.
*/
public void requestCancellation() {
if (isRunning()) {
@@ -190,6 +320,18 @@ public final class GuiBatchRunCoordinator {
return cancellationRequested.get();
}
+ // -------------------------------------------------------------------------
+ // Worker helpers
+ // -------------------------------------------------------------------------
+
+ private boolean startWorker(Runnable task) {
+ Thread worker = threadFactory.apply(task);
+ Objects.requireNonNull(worker, "threadFactory must not return null");
+ activeWorker.set(worker);
+ worker.start();
+ return true;
+ }
+
private void executeRun(Path configFilePath) {
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
configFilePath);
@@ -210,6 +352,58 @@ public final class GuiBatchRunCoordinator {
"Unerwarteter technischer Fehler: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
+ finishRun(outcome);
+ }
+
+ private void executeMiniRun(Path configFilePath, Set
+ * The {@code fingerprint} field is the content-based identity of the document and is
+ * used as a stable key for in-place row updates during a targeted mini-run.
+ *
+ * When {@code resetPending} is {@code true} the row represents a document whose
+ * persistence status has been deleted but which has not yet been reprocessed. The status
+ * icon and label reflect this special state instead of the original processing outcome.
*
- * @param originalFileName the source filename as reported by the use case; never
- * {@code null} or blank
- * @param status the aggregated completion status; never {@code null}
- * @param finalFileName the final target filename when the row represents a successful
- * rename; empty otherwise
- * @param resolvedDate the resolved document date when the row represents a successful
- * rename; empty otherwise
- * @param aiReasoning the AI reasoning shown in the side panel; empty when no
- * reasoning is available for this row
+ * @param originalFileName the source filename as reported by the use case; never
+ * {@code null} or blank
+ * @param fingerprint the content-based identity of the processed document; never
+ * {@code null}
+ * @param status the aggregated completion status; never {@code null}
+ * @param finalFileName the final target filename when the row represents a successful
+ * rename; empty otherwise
+ * @param resolvedDate the resolved document date when the row represents a successful
+ * rename; empty otherwise
+ * @param aiReasoning the AI reasoning shown in the side panel; empty when no
+ * reasoning is available for this row
* @param processingDuration wall-clock duration spent on the candidate in this run;
- * never {@code null} and never negative
+ * 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
*/
public record GuiBatchRunResultRow(
String originalFileName,
+ DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
Optional
+ * The returned row has {@code resetPending == true}. Its {@code statusIcon()} and
+ * {@code statusLabel()} reflect the reset state.
+ *
+ * @param previousRow the row to copy; must not be {@code null}
+ * @return a new row with the same filename and fingerprint, {@code resetPending == true}
+ * @throws NullPointerException if {@code previousRow} is {@code null}
+ */
+ public static GuiBatchRunResultRow resetMarker(GuiBatchRunResultRow previousRow) {
+ Objects.requireNonNull(previousRow, "previousRow must not be null");
+ return new GuiBatchRunResultRow(
+ previousRow.originalFileName(),
+ previousRow.fingerprint(),
+ previousRow.status(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Duration.ZERO,
+ true);
+ }
+
+ /**
+ * Returns the status icon for this row as a Unicode character that renders reliably
+ * in JavaFX on Windows.
+ *
+ * When {@code resetPending} is {@code true} the reset icon is returned regardless of
+ * the underlying status.
*
* @return the corresponding status character
*/
public String statusIcon() {
+ if (resetPending) {
+ return RESET_PENDING_ICON;
+ }
return switch (status) {
- case SUCCESS -> "\u2714"; // ✔ HEAVY CHECK MARK
- case FAILED_RETRYABLE -> "\u26A0"; // ⚠ WARNING SIGN (no variation selector)
+ 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
};
}
+
+ /**
+ * Returns the human-readable status label for this row.
+ *
+ * When {@code resetPending} is {@code true} the reset-pending label is returned
+ * regardless of the underlying status.
+ *
+ * @return a non-null German status label
+ */
+ public String statusLabel() {
+ if (resetPending) {
+ return RESET_PENDING_LABEL;
+ }
+ return switch (status) {
+ case SUCCESS -> "Erfolgreich";
+ case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
+ case FAILED_PERMANENT -> "Fehlgeschlagen (permanent)";
+ case SKIPPED -> "Übersprungen";
+ };
+ }
}
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 e98b1c0..3438cb3 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
@@ -3,28 +3,40 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
+import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
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;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
+import javafx.collections.ObservableSet;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.ScrollPane;
+import javafx.scene.control.SelectionMode;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
@@ -44,6 +56,11 @@ import javafx.scene.layout.VBox;
* inside the GUI. It collaborates with a {@link GuiBatchRunCoordinator} which owns the
* background worker thread and forwards progress callbacks here on the JavaFX Application
* Thread.
+ *
+ * After a run completes, the user may select one or more rows and trigger either
+ * "Erneut verarbeiten" (reset + immediate mini-run for selected documents) or
+ * "Status zurücksetzen" (reset only, for reprocessing in the next regular run).
+ * Selection is locked while any run or reset is active.
*
*
+ * Must be called on the JavaFX Application Thread.
+ *
+ * @param newRow the new row; must not be {@code null}
+ */
+ void upsertResultRowByFingerprint(GuiBatchRunResultRow newRow) {
+ for (int i = 0; i < resultItems.size(); i++) {
+ if (resultItems.get(i).fingerprint().equals(newRow.fingerprint())) {
+ resultItems.set(i, newRow);
+ return;
+ }
+ }
+ resultItems.add(newRow);
+ }
+
+ // -------------------------------------------------------------------------
+ // UI state management
+ // -------------------------------------------------------------------------
+
private void showMessage(String message) {
messageArea.setText(message);
}
@@ -473,6 +821,14 @@ public final class GuiBatchRunTab {
} else {
cancelButton.setDisable(coordinator.isCancellationRequested());
}
+ // Selection-action buttons: active only when not running and at least 1 row is selected.
+ boolean canAct = !running && !selectedRows.isEmpty();
+ reprocessButton.setDisable(!canAct);
+ resetStatusButton.setDisable(!canAct);
+ // Master checkbox disabled while running.
+ masterCheckBox.setDisable(running);
+ // Refresh cells so CheckBoxCells update their disabled state.
+ resultTable.refresh();
}
private void resetMetrics() {
@@ -492,7 +848,70 @@ public final class GuiBatchRunTab {
}
}
+ // -------------------------------------------------------------------------
+ // Static helpers
+ // -------------------------------------------------------------------------
+
+ private static String statusColor(DocumentCompletionStatus status) {
+ return switch (status) {
+ case SUCCESS -> "#2e7d32";
+ case FAILED_RETRYABLE -> "#e65100";
+ case FAILED_PERMANENT -> "#c62828";
+ case SKIPPED -> "#757575";
+ };
+ }
+
+ private static String formatDuration(Duration duration) {
+ double seconds = duration.toMillis() / 1000.0;
+ if (seconds < 10) {
+ return String.format("%.2f s", seconds);
+ }
+ return String.format("%.1f s", seconds);
+ }
+
+ private static String buildDetailText(GuiBatchRunResultRow row) {
+ StringBuilder builder = new StringBuilder();
+ builder.append("Originaldateiname: ").append(row.originalFileName()).append('\n');
+ if (row.resetPending()) {
+ builder.append('\n').append(GuiBatchRunResultRow.RESET_PENDING_LABEL);
+ return builder.toString();
+ }
+ row.finalFileName()
+ .ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
+ row.resolvedDate()
+ .ifPresent(date -> builder.append("Datum: ")
+ .append(DateTimeFormatter.ISO_LOCAL_DATE.format(date)).append('\n'));
+ builder.append('\n');
+ row.aiReasoning().ifPresentOrElse(
+ reasoning -> builder.append(reasoning),
+ () -> builder.append(NO_REASONING_TEXT));
+ return builder.toString();
+ }
+
+ private static GuiBatchRunLaunchOutcome rejectingMiniLaunch(
+ Path p, Set
+ * A mini-run applies the full processing pipeline — legacy migration, configuration
+ * loading, validation, SQLite schema initialisation, run-lock, use-case wiring, and
+ * execution — but limits processing to the supplied fingerprint set. Documents not in
+ * the set are silently skipped without any persistence side-effects.
+ *
+ *
+ * Implementations must be safe to call from a non-UI worker thread. They must not touch
+ * the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
+ * caller's concern. The call blocks until the run terminates (normally, after a
+ * cancellation, or after a hard failure).
+ *
+ *
+ * Implementations must not propagate checked exceptions. Unexpected runtime exceptions
+ * should be caught, logged, and returned as a
+ * {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
+ * well-defined terminal state.
+ */
+@FunctionalInterface
+public interface GuiMiniRunLauncher {
+
+ /**
+ * Executes a targeted batch run restricted to the supplied fingerprint set.
+ *
+ * @param configFilePath path of the {@code .properties} file to run against;
+ * must not be {@code null}; must exist and be readable
+ * @param fingerprintFilter the set of document fingerprints to process; must not be
+ * {@code null}; an empty set results in a completed run
+ * that processes nothing
+ * @param observer observer receiving start/completion/end callbacks; must
+ * not be {@code null}
+ * @param cancellationToken cancellation token the run polls between candidates; must
+ * not be {@code null}
+ * @return a description of how the run terminated; never {@code null}
+ */
+ GuiBatchRunLaunchOutcome launch(
+ Path configFilePath,
+ Set
+ * A reset deletes all persistence data (attempt history and document master record)
+ * for the specified fingerprints, making them eligible for reprocessing in the next
+ * regular or targeted batch run as if they had never been processed.
+ *
+ * The operation follows best-effort semantics: each fingerprint is attempted
+ * independently. Technical failures for individual fingerprints are recorded in the
+ * result and do not abort the remaining resets.
+ *
+ *
+ * Implementations must be safe to call from a non-UI worker thread. The call blocks
+ * until all reset operations have completed or failed.
+ *
+ *
+ * Implementations must not propagate checked exceptions. Unexpected runtime exceptions
+ * should be caught and represented as failures in the result map.
+ */
+@FunctionalInterface
+public interface GuiResetDocumentStatusPort {
+
+ /**
+ * Resets the processing status for the supplied set of document fingerprints.
+ *
+ * @param configFilePath path of the {@code .properties} file that identifies the
+ * SQLite database to operate on; must not be {@code null};
+ * must exist and be readable
+ * @param fingerprints the set of document fingerprints to reset; must not be
+ * {@code null}; may be empty
+ * @return a {@link ResetDocumentStatusResult} describing the full outcome; never null
+ */
+ ResetDocumentStatusResult reset(Path configFilePath, Set
+ * Idempotent: if no record with the given fingerprint exists the method returns
+ * without error. A {@link DocumentPersistenceException} is thrown only on technical
+ * database failures.
+ *
+ * @param fingerprint the document identity whose master record should be removed;
+ * must not be null
+ * @throws DocumentPersistenceException if the delete fails due to a technical error
+ */
+ @Override
+ public void deleteByFingerprint(DocumentFingerprint fingerprint) {
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
+
+ String sql = "DELETE FROM document_record WHERE fingerprint = ?";
+
+ try (Connection connection = getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+
+ statement.setString(1, fingerprint.sha256Hex());
+ int rowsAffected = statement.executeUpdate();
+ logger.debug("Deleted {} document_record row(s) for fingerprint: {}",
+ rowsAffected, fingerprint.sha256Hex());
+
+ } catch (SQLException e) {
+ String message = "Failed to delete document record for fingerprint '"
+ + fingerprint.sha256Hex() + "': " + e.getMessage();
+ logger.error(message, e);
+ throw new DocumentPersistenceException(message, e);
+ }
+ }
+
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
*
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java
index 3e24f18..5c2698f 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java
@@ -355,6 +355,39 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
return rs.wasNull() ? null : value;
}
+ /**
+ * Deletes all attempt history entries for the given fingerprint.
+ *
+ * Idempotent: if no attempts exist for the fingerprint the method returns without
+ * error. A {@link DocumentPersistenceException} is thrown only on technical database
+ * failures.
+ *
+ * @param fingerprint the document identity whose attempt records should be removed;
+ * must not be null
+ * @throws DocumentPersistenceException if the delete fails due to a technical error
+ */
+ @Override
+ public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
+
+ String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?";
+
+ try (Connection connection = getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+
+ statement.setString(1, fingerprint.sha256Hex());
+ int rowsAffected = statement.executeUpdate();
+ logger.debug("Deleted {} processing_attempt row(s) for fingerprint: {}",
+ rowsAffected, fingerprint.sha256Hex());
+
+ } catch (SQLException e) {
+ String message = "Failed to delete processing attempts for fingerprint '"
+ + fingerprint.sha256Hex() + "': " + e.getMessage();
+ logger.error(message, e);
+ throw new DocumentPersistenceException(message, e);
+ }
+ }
+
/**
* Returns the JDBC URL this adapter uses.
*
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java
index 8947c67..d113ac8 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java
@@ -15,6 +15,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceExcept
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* SQLite implementation of {@link UnitOfWorkPort}.
@@ -163,5 +164,39 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
};
repo.update(record);
}
+
+ /**
+ * Deletes all attempt history entries and the document master record for the
+ * given fingerprint within the current transaction.
+ *
+ * Attempts are deleted first to satisfy the foreign-key constraint between
+ * {@code processing_attempt} and {@code document_record}. Both deletes are
+ * idempotent: missing rows are silently ignored.
+ *
+ * @param fingerprint the document identity to fully reset; must not be null
+ * @throws DocumentPersistenceException if either delete fails
+ */
+ @Override
+ public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
+ // Delete attempts first (FK constraint: processing_attempt → document_record)
+ SqliteProcessingAttemptRepositoryAdapter attemptRepo =
+ new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
+ @Override
+ protected Connection getConnection() throws SQLException {
+ return nonClosingWrapper(connection);
+ }
+ };
+ attemptRepo.deleteAllByFingerprint(fingerprint);
+
+ // Then delete the master record
+ SqliteDocumentRecordRepositoryAdapter recordRepo =
+ new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
+ @Override
+ protected Connection getConnection() throws SQLException {
+ return nonClosingWrapper(connection);
+ }
+ };
+ recordRepo.deleteByFingerprint(fingerprint);
+ }
}
}
\ No newline at end of file
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFolderAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFolderAdapter.java
index 4267cbd..073b3ac 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFolderAdapter.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFolderAdapter.java
@@ -1,17 +1,23 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Filesystem-based implementation of {@link TargetFolderPort}.
@@ -67,27 +73,47 @@ public class FilesystemTargetFolderAdapter implements TargetFolderPort {
}
/**
- * Resolves the first available unique filename in the target folder for the given base name.
+ * Resolves the first available unique filename in the target folder for the given base name,
+ * applying an identical-content shortcut when the base name already exists.
*
- * Checks for {@code baseName} first; if taken, appends {@code (1)}, {@code (2)}, etc.
- * directly before {@code .pdf} until a free name is found.
+ * Processing order:
+ *
+ * Any I/O or digest error is treated as non-identical (returns {@code false} and logs
+ * at debug level), so the duplicate-suffix path is entered instead.
+ *
+ * @param targetPath path of the existing target file to compare
+ * @param sourceFingerprint expected SHA-256 hex digest of the source document
+ * @return {@code true} if the existing file's SHA-256 equals the source fingerprint
+ */
+ private boolean isIdenticalContent(Path targetPath, DocumentFingerprint sourceFingerprint) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ try (InputStream in = Files.newInputStream(targetPath)) {
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ digest.update(buffer, 0, read);
+ }
+ }
+ String targetHex = HexFormat.of().formatHex(digest.digest());
+ return targetHex.equalsIgnoreCase(sourceFingerprint.sha256Hex());
+ } catch (NoSuchAlgorithmException | IOException e) {
+ logger.debug("Could not compute SHA-256 of existing target file '{}': {} — "
+ + "treating as non-identical.", targetPath.getFileName(), e.getMessage());
+ return false;
+ }
+ }
+
/**
* Best-effort deletion of a file in the target folder.
*
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java
index 4ddc0e3..cd68993 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java
@@ -661,4 +661,47 @@ class SqliteDocumentRecordRepositoryAdapterTest {
assertThat(success.record().createdAt()).isEqualTo(createdAt);
assertThat(success.record().updatedAt()).isEqualTo(now);
}
+
+ // -------------------------------------------------------------------------
+ // deleteByFingerprint
+ // -------------------------------------------------------------------------
+
+ @Test
+ void deleteByFingerprint_removesExistingRecord() {
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+ DocumentRecord record = new DocumentRecord(
+ fingerprint,
+ new SourceDocumentLocator("/path/del.pdf"),
+ "del.pdf",
+ ProcessingStatus.PROCESSING,
+ FailureCounters.zero(),
+ null,
+ null,
+ Instant.now().truncatedTo(ChronoUnit.MICROS),
+ Instant.now().truncatedTo(ChronoUnit.MICROS),
+ null,
+ null
+ );
+ repository.create(record);
+ assertThat(repository.findByFingerprint(fingerprint))
+ .isNotInstanceOf(DocumentUnknown.class);
+
+ repository.deleteByFingerprint(fingerprint);
+
+ assertThat(repository.findByFingerprint(fingerprint))
+ .isInstanceOf(DocumentUnknown.class);
+ }
+
+ @Test
+ void deleteByFingerprint_isIdempotentWhenRecordAbsent() {
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
+
+ // Must not throw even if the record does not exist
+ repository.deleteByFingerprint(fingerprint);
+
+ assertThat(repository.findByFingerprint(fingerprint))
+ .isInstanceOf(DocumentUnknown.class);
+ }
}
\ No newline at end of file
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java
index 86c7060..0bd68c8 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java
@@ -883,4 +883,66 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
throw new RuntimeException("Failed to insert document record for testing", e);
}
}
+
+ // -------------------------------------------------------------------------
+ // deleteAllByFingerprint
+ // -------------------------------------------------------------------------
+
+ @Test
+ void deleteAllByFingerprint_removesAllAttemptsForFingerprint() {
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
+ RunId runId = new RunId("test-run-delete");
+ Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
+
+ insertDocumentRecord(fingerprint);
+
+ // Save two attempts
+ for (int i = 1; i <= 2; i++) {
+ repository.save(ProcessingAttempt.withoutAiFields(
+ fingerprint, runId, i, now, now.plusSeconds(i),
+ ProcessingStatus.FAILED_RETRYABLE, "SomeError", "message", true));
+ }
+ assertThat(repository.findAllByFingerprint(fingerprint)).hasSize(2);
+
+ repository.deleteAllByFingerprint(fingerprint);
+
+ assertThat(repository.findAllByFingerprint(fingerprint)).isEmpty();
+ }
+
+ @Test
+ void deleteAllByFingerprint_isIdempotentWhenNoAttemptsExist() {
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");
+
+ // Must not throw even if no attempts exist for this fingerprint
+ repository.deleteAllByFingerprint(fingerprint);
+
+ assertThat(repository.findAllByFingerprint(fingerprint)).isEmpty();
+ }
+
+ @Test
+ void deleteAllByFingerprint_doesNotAffectOtherFingerprints() {
+ DocumentFingerprint fp1 = new DocumentFingerprint(
+ "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
+ DocumentFingerprint fp2 = new DocumentFingerprint(
+ "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
+ RunId runId = new RunId("run-isolation");
+ Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
+
+ insertDocumentRecord(fp1);
+ insertDocumentRecord(fp2);
+
+ repository.save(ProcessingAttempt.withoutAiFields(
+ fp1, runId, 1, now, now.plusSeconds(1),
+ ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
+ repository.save(ProcessingAttempt.withoutAiFields(
+ fp2, runId, 1, now, now.plusSeconds(1),
+ ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
+
+ repository.deleteAllByFingerprint(fp1);
+
+ assertThat(repository.findAllByFingerprint(fp1)).isEmpty();
+ assertThat(repository.findAllByFingerprint(fp2)).hasSize(1);
+ }
}
\ No newline at end of file
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapterTest.java
index 6010060..f93cfe8 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapterTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapterTest.java
@@ -1,5 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -15,9 +16,12 @@ import org.junit.jupiter.api.io.TempDir;
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.DocumentUnknown;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
+import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
@@ -227,8 +231,68 @@ class SqliteUnitOfWorkAdapterTest {
unitOfWorkAdapter.executeInTransaction(txOps -> txOps.createDocumentRecord(record));
var result = docRepository.findByFingerprint(fingerprint);
- assertFalse(result instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown,
+ assertFalse(result instanceof DocumentUnknown,
"Record must be persisted and retrievable after a successfully committed transaction");
}
+ // -------------------------------------------------------------------------
+ // resetDocumentByFingerprint
+ // -------------------------------------------------------------------------
+
+ @Test
+ void resetDocumentByFingerprint_deletesMasterRecordAndAttempts() {
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
+ Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
+ DocumentRecord record = new DocumentRecord(
+ fingerprint,
+ new SourceDocumentLocator("/source/reset-test.pdf"),
+ "reset-test.pdf",
+ ProcessingStatus.PROCESSING,
+ FailureCounters.zero(),
+ null,
+ null,
+ now,
+ now,
+ null,
+ null
+ );
+
+ SqliteDocumentRecordRepositoryAdapter docRepository =
+ new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
+ SqliteProcessingAttemptRepositoryAdapter attemptRepository =
+ new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
+
+ // Persist master record and one attempt
+ unitOfWorkAdapter.executeInTransaction(txOps -> {
+ txOps.createDocumentRecord(record);
+ txOps.saveProcessingAttempt(ProcessingAttempt.withoutAiFields(
+ fingerprint, new RunId("run-reset"), 1, now, now.plusSeconds(1),
+ ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
+ });
+ assertThat(docRepository.findByFingerprint(fingerprint)).isNotInstanceOf(DocumentUnknown.class);
+ assertThat(attemptRepository.findAllByFingerprint(fingerprint)).hasSize(1);
+
+ // Reset
+ unitOfWorkAdapter.executeInTransaction(txOps ->
+ txOps.resetDocumentByFingerprint(fingerprint));
+
+ assertThat(docRepository.findByFingerprint(fingerprint)).isInstanceOf(DocumentUnknown.class);
+ assertThat(attemptRepository.findAllByFingerprint(fingerprint)).isEmpty();
+ }
+
+ @Test
+ void resetDocumentByFingerprint_isIdempotentWhenRecordAbsent() {
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
+
+ // Must not throw even if no record exists
+ unitOfWorkAdapter.executeInTransaction(txOps ->
+ txOps.resetDocumentByFingerprint(fingerprint));
+
+ SqliteDocumentRecordRepositoryAdapter docRepository =
+ new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
+ assertThat(docRepository.findByFingerprint(fingerprint)).isInstanceOf(DocumentUnknown.class);
+ }
+
}
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFolderAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFolderAdapterTest.java
index a84947a..08f4069 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFolderAdapterTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFolderAdapterTest.java
@@ -6,14 +6,18 @@ import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.HexFormat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Tests for {@link FilesystemTargetFolderAdapter}.
@@ -23,6 +27,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFail
*/
class FilesystemTargetFolderAdapterTest {
+ /** A fingerprint whose hex value differs from any real file content in tests. */
+ private static final DocumentFingerprint DUMMY_FP =
+ new DocumentFingerprint("0".repeat(64));
+
@TempDir
Path targetFolder;
@@ -57,7 +65,7 @@ class FilesystemTargetFolderAdapterTest {
void resolveUniqueFilename_noConflict_returnsBaseName() {
String baseName = "2026-01-15 - Rechnung.pdf";
- TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(baseName);
@@ -72,7 +80,7 @@ class FilesystemTargetFolderAdapterTest {
String baseName = "2026-01-15 - Rechnung.pdf";
Files.createFile(targetFolder.resolve(baseName));
- TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
@@ -85,7 +93,7 @@ class FilesystemTargetFolderAdapterTest {
Files.createFile(targetFolder.resolve(baseName));
Files.createFile(targetFolder.resolve("2026-01-15 - Rechnung(1).pdf"));
- TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
@@ -101,7 +109,7 @@ class FilesystemTargetFolderAdapterTest {
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(2).pdf"));
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(3).pdf"));
- TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
@@ -117,7 +125,7 @@ class FilesystemTargetFolderAdapterTest {
String baseName = "2026-04-07 - Bescheid.pdf";
Files.createFile(targetFolder.resolve(baseName));
- TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
@@ -137,7 +145,7 @@ class FilesystemTargetFolderAdapterTest {
String baseName = "2026-01-01 - " + title + ".pdf";
Files.createFile(targetFolder.resolve(baseName));
- TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
@@ -159,7 +167,7 @@ class FilesystemTargetFolderAdapterTest {
// Create a file with that name (no extension) to trigger conflict handling
Files.createFile(targetFolder.resolve(nameWithoutExt));
- TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt);
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt, DUMMY_FP);
// Without .pdf extension, suffix insertion fails
assertThat(result).isInstanceOf(TargetFolderTechnicalFailure.class);
@@ -174,7 +182,7 @@ class FilesystemTargetFolderAdapterTest {
// If the name does not exist, the adapter returns it without checking the extension
String nameWithoutExt = "2026-01-15 - Rechnung";
- TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt);
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(nameWithoutExt);
@@ -187,7 +195,7 @@ class FilesystemTargetFolderAdapterTest {
@Test
void resolveUniqueFilename_rejectsNullBaseName() {
assertThatNullPointerException()
- .isThrownBy(() -> adapter.resolveUniqueFilename(null));
+ .isThrownBy(() -> adapter.resolveUniqueFilename(null, DUMMY_FP));
}
// -------------------------------------------------------------------------
@@ -240,7 +248,7 @@ class FilesystemTargetFolderAdapterTest {
// Files.exists() on a file in a non-existent folder does not throw;
// it simply returns false, so the adapter returns the base name.
// This is consistent behaviour: no folder access error when just checking existence.
- TargetFilenameResolutionResult result = adapterWithMissingFolder.resolveUniqueFilename(baseName);
+ TargetFilenameResolutionResult result = adapterWithMissingFolder.resolveUniqueFilename(baseName, DUMMY_FP);
// Adapter returns the base name since no conflict is detected for a non-existent folder
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
@@ -256,4 +264,48 @@ class FilesystemTargetFolderAdapterTest {
assertThatNullPointerException()
.isThrownBy(() -> new FilesystemTargetFolderAdapter(null));
}
+
+ // -------------------------------------------------------------------------
+ // resolveUniqueFilename – identical-content shortcut
+ // -------------------------------------------------------------------------
+
+ @Test
+ void resolveUniqueFilename_existingFileWithIdenticalContent_returnsExistingIdenticalTargetFile()
+ throws Exception {
+ // Arrange: write a file with known content and compute its SHA-256
+ String baseName = "2026-01-15 - Identisch.pdf";
+ byte[] content = "identical content for test".getBytes();
+ Files.write(targetFolder.resolve(baseName), content);
+
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update(content);
+ String sha256Hex = HexFormat.of().formatHex(digest.digest());
+ DocumentFingerprint matchingFp = new DocumentFingerprint(sha256Hex);
+
+ // Act
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, matchingFp);
+
+ // Assert: shortcut path returns ExistingIdenticalTargetFile, not a new suffix
+ assertThat(result).isInstanceOf(ExistingIdenticalTargetFile.class);
+ assertThat(((ExistingIdenticalTargetFile) result).existingFilename()).isEqualTo(baseName);
+ }
+
+ @Test
+ void resolveUniqueFilename_existingFileWithDifferentContent_returnsSuffixedFilename()
+ throws IOException {
+ // Arrange: existing file with some content; source fingerprint differs
+ String baseName = "2026-01-15 - Verschieden.pdf";
+ Files.write(targetFolder.resolve(baseName), "existing content".getBytes());
+
+ // Use a fingerprint whose hex does not match the SHA-256 of the existing file
+ DocumentFingerprint differentFp = new DocumentFingerprint("0".repeat(64));
+
+ // Act
+ TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, differentFp);
+
+ // Assert: different content → suffix appended
+ assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
+ assertThat(((ResolvedTargetFilename) result).resolvedFilename())
+ .isEqualTo("2026-01-15 - Verschieden(1).pdf");
+ }
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java
index b429a11..731b5b6 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java
@@ -4,6 +4,8 @@ import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
/**
* Immutable event describing the outcome of processing exactly one candidate document.
*
@@ -16,6 +18,8 @@ import java.util.Objects;
*
* @param originalFileName the source candidate's unique identifier (typically the source
* filename); never {@code null} or blank
+ * @param fingerprint the content-based identity of the processed document;
+ * never {@code null}
* @param status the aggregated outcome status; never {@code null}
* @param finalFileName the final target filename, including any duplicate suffix;
* never {@code null} for {@link DocumentCompletionStatus#SUCCESS},
@@ -32,6 +36,7 @@ import java.util.Objects;
*/
public record DocumentCompletionEvent(
String originalFileName,
+ DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
String finalFileName,
LocalDate resolvedDate,
@@ -41,8 +46,9 @@ public record DocumentCompletionEvent(
/**
* Compact constructor validating mandatory fields.
*
- * @throws NullPointerException if {@code originalFileName}, {@code status} or
- * {@code processingDuration} is {@code null}
+ * @throws NullPointerException if {@code originalFileName}, {@code fingerprint},
+ * {@code status} or {@code processingDuration} is
+ * {@code null}
* @throws IllegalArgumentException if {@code originalFileName} is blank or
* {@code processingDuration} is negative
*/
@@ -51,6 +57,7 @@ public record DocumentCompletionEvent(
if (originalFileName.isBlank()) {
throw new IllegalArgumentException("originalFileName must not be blank");
}
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(status, "status must not be null");
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
if (processingDuration.isNegative()) {
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResetDocumentStatusResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResetDocumentStatusResult.java
new file mode 100644
index 0000000..a70ccee
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ResetDocumentStatusResult.java
@@ -0,0 +1,64 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
+/**
+ * Immutable summary of a {@link ResetDocumentStatusUseCase#reset(Set)} invocation.
+ *
+ * Reports how many documents were requested for reset, which were successfully reset,
+ * and which encountered a technical failure. Callers can use this record to present
+ * a user-visible result or decide on follow-up actions.
+ *
+ * @param requestedCount total number of fingerprints that were passed to the reset
+ * operation; always >= 0
+ * @param successfullyReset set of fingerprints that were successfully deleted from
+ * persistence; never null
+ * @param failures map of fingerprint → error message for every fingerprint
+ * whose reset operation encountered a technical failure;
+ * never null
+ */
+public record ResetDocumentStatusResult(
+ int requestedCount,
+ Set
+ * A reset removes all persistence data (attempt history and document master record)
+ * for the specified fingerprints, making those documents eligible for reprocessing in
+ * the next regular or targeted batch run as if they had never been processed.
+ *
+ * The operation follows a best-effort semantics: each fingerprint is processed
+ * independently. A technical failure for one fingerprint does not prevent the reset
+ * from being attempted for the remaining fingerprints. The result carries the full
+ * accounting of successes and failures.
+ */
+public interface ResetDocumentStatusUseCase {
+
+ /**
+ * Resets the processing status for the supplied set of document fingerprints.
+ *
+ * For each fingerprint the implementation deletes the document master record and
+ * all associated attempt history within a single atomic transaction. If the
+ * transaction fails for a given fingerprint, that fingerprint's error is recorded
+ * in the result's {@link ResetDocumentStatusResult#failures() failures} map and
+ * processing continues with the remaining fingerprints.
+ *
+ * @param fingerprints the set of document fingerprints to reset; must not be null;
+ * may be empty (results in a completed result with zero requests)
+ * @return a {@link ResetDocumentStatusResult} describing the outcome; never null
+ */
+ ResetDocumentStatusResult reset(Set
+ * This operation is idempotent: if no record exists for the fingerprint, the method
+ * returns without error. A {@link DocumentPersistenceException} is thrown only on
+ * technical failures such as database connectivity errors.
+ *
+ * @param fingerprint the document identity whose master record should be removed;
+ * must not be null
+ * @throws DocumentPersistenceException if the delete fails due to a technical error
+ */
+ void deleteByFingerprint(DocumentFingerprint fingerprint);
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ExistingIdenticalTargetFile.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ExistingIdenticalTargetFile.java
new file mode 100644
index 0000000..2f1bdce
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ExistingIdenticalTargetFile.java
@@ -0,0 +1,33 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+import java.util.Objects;
+
+/**
+ * Outcome of {@link TargetFolderPort#resolveUniqueFilename(String)} when the target file
+ * at the proposed base name already exists and its binary content is identical
+ * to the source document (same SHA-256 fingerprint).
+ *
+ * This result signals to the application layer that no new copy is needed: the existing
+ * target file is byte-for-byte identical to the source. The processing coordinator treats
+ * this as a successful outcome — the document is considered already present in the target
+ * folder under the given filename.
+ *
+ * @param existingFilename the filename of the already-existing identical target file,
+ * including extension; never null or blank
+ */
+public record ExistingIdenticalTargetFile(String existingFilename)
+ implements TargetFilenameResolutionResult {
+
+ /**
+ * Compact constructor validating the filename.
+ *
+ * @throws NullPointerException if {@code existingFilename} is null
+ * @throws IllegalArgumentException if {@code existingFilename} is blank
+ */
+ public ExistingIdenticalTargetFile {
+ Objects.requireNonNull(existingFilename, "existingFilename must not be null");
+ if (existingFilename.isBlank()) {
+ throw new IllegalArgumentException("existingFilename must not be blank");
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttemptRepository.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttemptRepository.java
index 7efb3d8..e0c73d5 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttemptRepository.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttemptRepository.java
@@ -88,4 +88,17 @@ public interface ProcessingAttemptRepository {
* @throws DocumentPersistenceException if the query fails due to a technical error
*/
ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint);
+
+ /**
+ * Deletes all attempt history entries for the given fingerprint.
+ *
+ * This operation is idempotent: if no attempts exist for the fingerprint, the method
+ * returns without error. A {@link DocumentPersistenceException} is thrown only on
+ * technical failures such as database connectivity errors.
+ *
+ * @param fingerprint the document identity whose attempt records should be removed;
+ * must not be null
+ * @throws DocumentPersistenceException if the delete fails due to a technical error
+ */
+ void deleteAllByFingerprint(DocumentFingerprint fingerprint);
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFilenameResolutionResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFilenameResolutionResult.java
index afbf423..5fa4a18 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFilenameResolutionResult.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFilenameResolutionResult.java
@@ -3,12 +3,15 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Sealed result type for {@link TargetFolderPort#resolveUniqueFilename(String)}.
*
- * Permits exactly two outcomes:
+ * Permits exactly three outcomes:
*
@@ -21,6 +23,15 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
* purely a technical collision-avoidance mechanism and introduces no new fachliche
* title interpretation.
*
+ *
+ * Before appending any numeric suffix, the implementation checks whether the base name
+ * already exists in the target folder and whether that existing file is
+ * byte-for-byte identical to the source document (verified via the supplied
+ * {@link DocumentFingerprint}). When both conditions hold, the method returns
+ * {@link ExistingIdenticalTargetFile} instead of {@link ResolvedTargetFilename},
+ * signalling that no new copy is required.
+ *
*
* No {@code Path}, {@code File}, or NIO types appear in this interface. The concrete
@@ -41,22 +52,32 @@ public interface TargetFolderPort {
String getTargetFolderLocator();
/**
- * Resolves the first available unique filename in the target folder for the given base name.
+ * Resolves the first available unique filename in the target folder for the given base name,
+ * taking the source document's fingerprint into account for identity-based shortcutting.
*
- * If the base name is not yet taken, it is returned unchanged. Otherwise the method
- * appends {@code (1)}, {@code (2)}, etc. directly before {@code .pdf} until a free
- * name is found.
+ * Processing order:
+ *
* The returned filename contains only the file name, not the full path. It is safe
* to use as the {@code resolvedFilename} parameter of
* {@link TargetFileCopyPort#copyToTarget(de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator, String)}.
*
- * @param baseName the desired filename including the {@code .pdf} extension;
- * must not be null or blank
- * @return a {@link ResolvedTargetFilename} with the first available name, or a
- * {@link TargetFolderTechnicalFailure} if the target folder is not accessible
+ * @param baseName the desired filename including the {@code .pdf} extension;
+ * must not be null or blank
+ * @param sourceFingerprint the SHA-256 fingerprint of the source document used for
+ * identical-content detection; must not be null
+ * @return a {@link ResolvedTargetFilename}, {@link ExistingIdenticalTargetFile}, or
+ * {@link TargetFolderTechnicalFailure}
*/
- TargetFilenameResolutionResult resolveUniqueFilename(String baseName);
+ TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint);
/**
* Best-effort attempt to delete a file previously written to the target folder.
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java
index f8484c3..e187e4c 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/UnitOfWorkPort.java
@@ -2,15 +2,16 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.function.Consumer;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
/**
* Port for executing multiple repository operations within a single unit of work.
*
* Ensures that related persistence operations (such as saving a processing attempt
* and updating a document record) are executed atomically.
- *
*/
public interface UnitOfWorkPort {
-
+
/**
* Executes the given operations within a single unit of work.
*
@@ -20,13 +21,44 @@ public interface UnitOfWorkPort {
* @throws DocumentPersistenceException if any operation fails
*/
void executeInTransaction(Consumer
+ * Deletion order must respect foreign-key constraints: attempt history rows are
+ * removed first, then the master record. This operation is idempotent — if no
+ * data exists for the fingerprint the method returns silently.
+ *
+ * @param fingerprint the document identity to fully reset; must not be null
+ * @throws DocumentPersistenceException if the delete fails due to a technical error
+ */
+ void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
}
}
\ No newline at end of file
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java
index 7a02126..9b88475 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java
@@ -17,6 +17,7 @@ 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.ExistingIdenticalTargetFile;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
@@ -164,8 +165,8 @@ public class DocumentProcessingCoordinator {
/**
* Optional per-run completion forwarder that is consulted by
- * {@link #publishCompletion(SourceDocumentCandidate, DocumentCompletionStatus, String,
- * LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
+ * {@link #publishCompletion(SourceDocumentCandidate, DocumentFingerprint, DocumentCompletionStatus,
+ * String, LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
*
* Assigned by the inbound use case for the duration of a single run and cleared before the
* use case returns. A {@code null} value means no external observer is attached and the
@@ -490,8 +491,10 @@ public class DocumentProcessingCoordinator {
String baseFilename = ((TargetFilenameBuildingService.BaseFilenameReady) filenameResult).baseFilename();
// --- Step 3: Resolve unique filename in target folder ---
+ // Passing the source fingerprint enables the adapter to detect an identical existing
+ // target file and return ExistingIdenticalTargetFile instead of a numbered suffix.
TargetFilenameResolutionResult resolutionResult =
- targetFolderPort.resolveUniqueFilename(baseFilename);
+ targetFolderPort.resolveUniqueFilename(baseFilename, fingerprint);
if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
logger.error("Duplicate resolution failed for '{}': {}",
@@ -501,6 +504,20 @@ public class DocumentProcessingCoordinator {
"Target folder duplicate resolution failed: " + folderFailure.errorMessage());
}
+ // Identical-content shortcut: target already exists with the same content — treat as
+ // SUCCESS without writing a new copy.
+ if (resolutionResult instanceof ExistingIdenticalTargetFile identicalFile) {
+ logger.info("Target file '{}' already exists with identical content for '{}' "
+ + "(fingerprint: {}). Treating as success without new copy.",
+ identicalFile.existingFilename(), candidate.uniqueIdentifier(),
+ fingerprint.sha256Hex());
+ return persistTargetCopySuccess(
+ candidate, fingerprint, existingRecord, context, attemptStart, now,
+ identicalFile.existingFilename(),
+ targetFolderPort.getTargetFolderLocator(),
+ proposalAttempt);
+ }
+
String resolvedFilename =
((ResolvedTargetFilename) resolutionResult).resolvedFilename();
logger.info("Generated target filename for '{}' (fingerprint: {}): '{}'.",
@@ -597,7 +614,7 @@ public class DocumentProcessingCoordinator {
logger.info("Document '{}' successfully processed. Target: '{}'.",
candidate.uniqueIdentifier(), resolvedFilename);
- publishCompletion(candidate, DocumentCompletionStatus.SUCCESS,
+ publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SUCCESS,
resolvedFilename,
proposalAttempt.resolvedDate(),
proposalAttempt.aiReasoning(),
@@ -681,7 +698,7 @@ public class DocumentProcessingCoordinator {
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
updatedCounters.transientErrorCount(), maxRetriesTransient);
}
- publishCompletion(candidate,
+ publishCompletion(candidate, fingerprint,
retryable ? DocumentCompletionStatus.FAILED_RETRYABLE
: DocumentCompletionStatus.FAILED_PERMANENT,
null, null, null, attemptStart, now);
@@ -750,7 +767,7 @@ public class DocumentProcessingCoordinator {
// completion event keeps the observer in sync with the user-visible state even though
// nothing new was persisted.
String reasoning = proposalAttempt != null ? proposalAttempt.aiReasoning() : null;
- publishCompletion(candidate,
+ publishCompletion(candidate, fingerprint,
transition.retryable()
? DocumentCompletionStatus.FAILED_RETRYABLE
: DocumentCompletionStatus.FAILED_PERMANENT,
@@ -797,7 +814,7 @@ public class DocumentProcessingCoordinator {
logger.debug("Skip attempt #{} persisted for '{}' with status {}.",
attemptNumber, candidate.uniqueIdentifier(), skipStatus);
- publishCompletion(candidate, DocumentCompletionStatus.SKIPPED,
+ publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SKIPPED,
null, null, null, attemptStart, now);
return true;
@@ -1067,7 +1084,7 @@ public class DocumentProcessingCoordinator {
// PROPOSAL_READY is an intermediate state; the subsequent finalisation publishes
// the actual completion event (SUCCESS or transient-error failure).
if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) {
- publishCompletion(candidate, toCompletionStatus(outcome),
+ publishCompletion(candidate, fingerprint, toCompletionStatus(outcome),
null, null, null, attemptStart, now);
}
return true;
@@ -1200,6 +1217,7 @@ public class DocumentProcessingCoordinator {
* not affect persistence or batch flow.
*
* @param candidate the candidate being reported; must not be null
+ * @param fingerprint the content-based identity of the document; must not be null
* @param status the aggregated completion status; must not be null
* @param finalFileName the final target filename on success; {@code null} otherwise
* @param resolvedDate the resolved date on success; may be {@code null} otherwise
@@ -1210,6 +1228,7 @@ public class DocumentProcessingCoordinator {
*/
private void publishCompletion(
SourceDocumentCandidate candidate,
+ DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
String finalFileName,
LocalDate resolvedDate,
@@ -1227,6 +1246,7 @@ public class DocumentProcessingCoordinator {
try {
forwarder.accept(new DocumentCompletionEvent(
candidate.uniqueIdentifier(),
+ fingerprint,
status,
finalFileName,
resolvedDate,
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java
index e07ff2d..e315572 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java
@@ -1,8 +1,11 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
@@ -16,6 +19,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
@@ -42,6 +46,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
*
+ * When the {@link BatchRunContext} carries a fingerprint filter, the run restricts
+ * processing to exactly those candidates whose SHA-256 fingerprint is contained in
+ * the filter. Candidates not in the filter are silently skipped — no completion event
+ * is emitted, no persistence record is written, and they do not count toward the
+ * progress total reported to the {@link BatchRunProgressObserver}.
+ *
+ * To provide the correct total count for the progress bar, fingerprints of all source
+ * candidates are computed up front before the observer is notified of the run start.
+ * Only filter-matching candidates are included in the total and the processing loop.
+ *
*
* Documents are identified exclusively by their SHA-256 content fingerprint. A document
@@ -73,7 +91,6 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
* For every identified document, the processing attempt and the master record are
* written in sequence by {@link DocumentProcessingCoordinator}. Persistence failures for a single
* document are caught and logged; the batch run continues with the remaining candidates.
- *
*/
public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCase {
@@ -206,7 +223,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
}
/**
- * Loads candidates and processes them one by one.
+ * Loads candidates and processes them one by one, respecting any fingerprint filter
+ * present on the {@link BatchRunContext}.
*
* Document-level failures — including content errors, transient technical errors,
* and individual persistence failures — do not affect the batch outcome. The batch
@@ -217,26 +235,43 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
*
* Only a hard source folder access failure ({@link SourceDocumentAccessException}) prevents
* the batch from running at all, in which case {@link BatchRunOutcome#FAILURE} is returned.
+ *
+ * When a fingerprint filter is active, all source-folder candidates are scanned but their
+ * fingerprints are computed up front to determine which candidates belong to the effective
+ * candidate list. Only filter-matching candidates count toward the total reported to the
+ * observer and are included in the processing loop.
*
* @param context the current batch run context
* @return {@link BatchRunOutcome#SUCCESS} after all candidates have been processed,
* or {@link BatchRunOutcome#FAILURE} if the source folder is inaccessible
*/
private BatchRunOutcome processCandidates(BatchRunContext context) {
- List
+ * Candidates for which fingerprint computation fails are logged at warn level and
+ * excluded from the effective list (consistent with the regular per-candidate
+ * fingerprint-error handling).
+ *
+ * @param allCandidates all candidates from the source folder scan
+ * @param filter the set of fingerprints to match against
+ * @param context the current batch run context (used for logging)
+ * @return the ordered sub-list of candidates whose fingerprints are in the filter
+ */
+ private List
+ * For each requested fingerprint, this implementation deletes the document master
+ * record and all associated attempt history in a single atomic transaction via
+ * {@link UnitOfWorkPort}. Deletion order honours the foreign-key constraint:
+ * attempt rows are removed before the master record.
+ *
+ * The operation applies best-effort semantics: every fingerprint is attempted
+ * independently. A technical failure for one fingerprint is caught, logged, and
+ * recorded in the result's failure map; the remaining fingerprints continue to be
+ * processed. The batch never aborts early.
+ */
+public class DefaultResetDocumentStatusUseCase implements ResetDocumentStatusUseCase {
+
+ private final UnitOfWorkPort unitOfWorkPort;
+ private final ProcessingLogger logger;
+
+ /**
+ * Creates the use case with the required persistence port and logger.
+ *
+ * @param unitOfWorkPort port for executing the delete operations atomically;
+ * must not be null
+ * @param logger for operation-level logging; must not be null
+ * @throws NullPointerException if any parameter is null
+ */
+ public DefaultResetDocumentStatusUseCase(
+ UnitOfWorkPort unitOfWorkPort,
+ ProcessingLogger logger) {
+ this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null");
+ this.logger = Objects.requireNonNull(logger, "logger must not be null");
+ }
+
+ /**
+ * Resets the processing status for the supplied set of document fingerprints.
+ *
+ * Each fingerprint is processed independently. Technical failures for individual
+ * fingerprints are caught, logged at error level, and recorded in the result;
+ * they do not abort processing of the remaining fingerprints.
+ *
+ * @param fingerprints the set of document fingerprints to reset; must not be null;
+ * may be empty
+ * @return a {@link ResetDocumentStatusResult} describing the full outcome; never null
+ * @throws NullPointerException if {@code fingerprints} is null
+ */
+ @Override
+ public ResetDocumentStatusResult reset(Set
+ * Covers the happy path, per-fingerprint failure isolation, empty-set handling,
+ * null-guard on the fingerprint set, and best-effort continuation after failure.
+ */
+class DefaultResetDocumentStatusUseCaseTest {
+
+ private static final DocumentFingerprint FP1 =
+ new DocumentFingerprint("1".repeat(64));
+ private static final DocumentFingerprint FP2 =
+ new DocumentFingerprint("2".repeat(64));
+ private static final DocumentFingerprint FP3 =
+ new DocumentFingerprint("3".repeat(64));
+
+ // -------------------------------------------------------------------------
+ // Happy path
+ // -------------------------------------------------------------------------
+
+ @Test
+ void reset_allSucceed_returnsFullSuccessResult() {
+ List
+ * Mirrors the full headless bootstrap pipeline (legacy migration, configuration loading
+ * and validation, SQLite schema initialisation, run-lock acquisition, use-case wiring,
+ * and execution) but builds a {@link BatchRunContext} with a fingerprint filter so only
+ * the specified documents are processed.
+ *
+ * The run lock is acquired to prevent racing with a concurrent headless run. If the lock
+ * is unavailable, all fingerprints are returned as failures with a German message.
+ *
+ * @param configFilePath path to the {@code .properties} configuration; must exist on disk
+ * @param fingerprintFilter the set of document fingerprints to process; must not be null
+ * @param progressObserver observer forwarded into the use case; must not be null
+ * @param cancellationToken token forwarded into the use case; must not be null
+ * @return the outcome for the mini-run; never null
+ */
+ GuiBatchRunLaunchOutcome launchGuiMiniBatchRun(
+ Path configFilePath,
+ Set
+ * This method initialises the SQLite schema (if needed) and runs
+ * {@link DefaultResetDocumentStatusUseCase} with best-effort semantics. The run lock is
+ * acquired to avoid racing with a concurrent headless run. If the lock is unavailable,
+ * all fingerprints are returned as failures with a German message.
+ *
+ * Configuration migration and validation are applied before the reset executes to ensure
+ * the database path is resolvable.
+ *
+ * @param configFilePath path to the {@code .properties} configuration; must exist on disk
+ * @param fingerprints the set of document fingerprints to reset; must not be null
+ * @return the result of the reset operation; never null
+ */
+ ResetDocumentStatusResult resetDocumentStatusForGui(
+ Path configFilePath,
+ Set
+ * Used to produce a consistently structured failure result when a hard prerequisite
+ * (configuration load, lock acquisition) prevents any individual reset from running.
+ *
+ * @param fingerprints the full set of requested fingerprints; must not be null
+ * @param errorMessage the German error message to attach to every failure entry
+ * @return a result with all fingerprints in the failure map; never null
+ */
+ private static ResetDocumentStatusResult allFailures(
+ Set
* This context is independent of individual document processing and contains
- * no business logic. It is purely a technical container for run identity and timing.
+ * no business logic. It is purely a technical container for run identity, timing,
+ * and optional candidate restriction.
+ *
+ * When no fingerprint filter is set ({@link #fingerprintFilter()} returns empty),
+ * the run processes all candidates found in the source folder (regular batch run).
+ * When a non-empty filter is present, only candidates whose fingerprint is contained
+ * in that set are processed.
*/
public final class BatchRunContext {
private final RunId runId;
private final Instant startInstant;
private Instant endInstant;
+ private final Set
+ * No fingerprint filter is applied; all candidates from the source folder are
+ * eligible for processing.
*
* The end instant is initially null and may be set later via {@link #setEndInstant(Instant)}.
*
- * @param runId the unique identifier for this run, must not be null
- * @param startInstant the moment when the run started, must not be null
- * @throws NullPointerException if runId or startInstant is null
+ * @param runId the unique identifier for this run; must not be null
+ * @param startInstant the moment when the run started; must not be null
+ * @throws NullPointerException if {@code runId} or {@code startInstant} is null
*/
public BatchRunContext(RunId runId, Instant startInstant) {
+ this(runId, startInstant, null);
+ }
+
+ /**
+ * Creates a new BatchRunContext, optionally restricting the run to a specific
+ * set of document fingerprints.
+ *
+ * When {@code fingerprintFilter} is non-null and non-empty, the batch run
+ * processes only candidates whose computed fingerprint is contained in the
+ * supplied set. An empty set results in a run that processes nothing.
+ * A {@code null} value is treated identically to a regular unfiltered run.
+ *
+ * The supplied set is defensively copied; modifications to the original set
+ * after construction have no effect on this context.
+ *
+ * The end instant is initially null and may be set later via {@link #setEndInstant(Instant)}.
+ *
+ * @param runId the unique identifier for this run; must not be null
+ * @param startInstant the moment when the run started; must not be null
+ * @param fingerprintFilter the set of fingerprints to restrict processing to,
+ * or {@code null} for an unfiltered run
+ * @throws NullPointerException if {@code runId} or {@code startInstant} is null
+ */
+ public BatchRunContext(RunId runId, Instant startInstant, Set
+ * The returned context is independent; changes to the supplied set after this
+ * call have no effect on the new context.
+ *
+ * @param filter the set of fingerprints to restrict processing to;
+ * {@code null} is treated as no filter (regular run)
+ * @return a new context with the supplied filter applied; never null
+ */
+ public BatchRunContext withFingerprintFilter(Set
+ * When the returned optional is empty, no restriction applies and all source-folder
+ * candidates are eligible (regular batch run). When present, only candidates whose
+ * computed fingerprint is contained in the returned set are processed.
+ *
+ * @return an {@link Optional} containing the immutable fingerprint filter set,
+ * or an empty optional for a regular unfiltered run
+ */
+ public OptionalThreading
*
- *
Lifecycle
*
- *
*/
public final class GuiBatchRunCoordinator {
@@ -56,7 +63,7 @@ public final class GuiBatchRunCoordinator {
private static final String WORKER_THREAD_NAME = "gui-batch-run";
/**
- * Listener interface invoked on the JavaFX Application Thread during a run.
+ * Listener interface invoked on the JavaFX Application Thread during a run or reset.
*/
public interface Listener {
@@ -84,9 +91,24 @@ public final class GuiBatchRunCoordinator {
* @param outcome a description of how the run terminated; never {@code null}
*/
void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);
+
+ /**
+ * Invoked once after a reset-only operation has completed on the worker thread.
+ * Layout
*
@@ -51,8 +68,10 @@ import javafx.scene.layout.VBox;
* │ [Fortschrittsbalken] 12 / 47 Dateien │
* ├──────────────────────────────────┬───────────────────┤
* │ Ergebnisliste │ Seitenbereich │
- * │ (TableView) │ (Reasoning) │
+ * │ (TableView mit Checkbox-Spalte) │ (Reasoning) │
* ├──────────────────────────────────┴───────────────────┤
+ * │ [Erneut verarbeiten] [Status zurücksetzen] │
+ * ├──────────────────────────────────────────────────────┤
* │ Meldungs- und Zusammenfassungsbereich │
* ├──────────────────────────────────────────────────────┤
* │ [Starten] [Abbrechen] │
@@ -95,6 +114,7 @@ public final class GuiBatchRunTab {
private static final double DETAIL_PANE_MIN_WIDTH = 280;
private static final double LIST_MIN_HEIGHT = 240;
private static final double DETAIL_AREA_MIN_HEIGHT = 240;
+ private static final double CHECKBOX_COL_WIDTH = 40;
private static final int SECONDARY_SPACING = 12;
private final Tab tab = new Tab(TAB_TITLE);
@@ -102,10 +122,45 @@ public final class GuiBatchRunTab {
private final Label counterLabel = new Label("0 / 0 Dateien");
private final TableViewThreading
+ * Exception contract
+ * Threading
+ * Exception contract
+ *
+ *
*
- * @param baseName the desired filename including {@code .pdf} extension;
- * must not be null or blank
- * @return a {@link ResolvedTargetFilename} with the first available name, or a
+ * @param baseName the desired filename including {@code .pdf} extension;
+ * must not be null or blank
+ * @param sourceFingerprint the SHA-256 fingerprint of the source document; must not be null
+ * @return a {@link ResolvedTargetFilename}, {@link ExistingIdenticalTargetFile}, or
* {@link TargetFolderTechnicalFailure} if folder access fails
*/
@Override
- public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
+ public TargetFilenameResolutionResult resolveUniqueFilename(
+ String baseName, DocumentFingerprint sourceFingerprint) {
Objects.requireNonNull(baseName, "baseName must not be null");
+ Objects.requireNonNull(sourceFingerprint, "sourceFingerprint must not be null");
try {
+ Path baseNamePath = targetFolderPath.resolve(baseName);
+
// Try without suffix first
- if (!Files.exists(targetFolderPath.resolve(baseName))) {
+ if (!Files.exists(baseNamePath)) {
logger.debug("Resolved target filename without suffix: '{}'", baseName);
return new ResolvedTargetFilename(baseName);
}
+ // The base name exists — check for identical content before adding a suffix
+ if (isIdenticalContent(baseNamePath, sourceFingerprint)) {
+ logger.debug("Target file '{}' already exists with identical content — no new copy needed.",
+ baseName);
+ return new ExistingIdenticalTargetFile(baseName);
+ }
+
// Determine split point: everything before the final ".pdf"
if (!baseName.toLowerCase().endsWith(".pdf")) {
return new TargetFolderTechnicalFailure(
@@ -115,6 +141,36 @@ public class FilesystemTargetFolderAdapter implements TargetFolderPort {
}
}
+ /**
+ * Returns {@code true} when the SHA-256 digest of the file at {@code targetPath} matches
+ * the hex value in {@code sourceFingerprint}.
+ *
*
*/
public sealed interface TargetFilenameResolutionResult
- permits ResolvedTargetFilename, TargetFolderTechnicalFailure {
+ permits ResolvedTargetFilename, ExistingIdenticalTargetFile, TargetFolderTechnicalFailure {
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFolderPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFolderPort.java
index 19e7971..3aa178e 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFolderPort.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFolderPort.java
@@ -1,5 +1,7 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
/**
* Outbound port for target folder access: duplicate resolution and best-effort cleanup.
* Identical-content shortcut
+ * Architecture boundary
*
+ *
*
*
*
+ * Fingerprint filter (mini-run)
+ * Idempotency
*
*
*