Fix #31: Manuelle Dateinamen-Eingabe für nicht verarbeitete Dateien

Nicht-erfolgreiche Zeilen (FAILED, FAILED_RETRYABLE, SKIPPED_FINAL_FAILURE)
können im Detailbereich des Verarbeitungslauf-Tabs nun einen manuellen
Zieldateinamen erhalten. Beim Bestätigen wird die Quelldatei mit dem
benutzerdefinierten Namen ins Zielverzeichnis kopiert und der Stammsatz
atomar auf SUCCESS gehoben.

Neuer Inbound-Port ManualFileCopyUseCase mit sealed Result-Hierarchie,
Default-Implementierung mit Best-Effort-Rollback bei Persistenzfehler
sowie GUI-Brücke GuiManualFileCopyPort. Die GUI entscheidet anhand des
Status zwischen Umbenennen (SUCCESS, SKIPPED_ALREADY_PROCESSED) und
Kopieren (FAILED_*, SKIPPED_FINAL_FAILURE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 13:22:44 +02:00
parent fb0e9809f6
commit 1d77173c49
18 changed files with 1673 additions and 23 deletions
@@ -18,6 +18,7 @@ import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalDocumentContextPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
@@ -372,6 +373,13 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiManualFileRenamePort manualFileRenamePort;
/**
* Port used by the processing-run tab to copy a source file to the target folder for
* documents that have not yet been successfully processed (FAILED- oder SKIPPED-Status).
* Supplied by Bootstrap via the startup context.
*/
private final GuiManualFileCopyPort manualFileCopyPort;
/**
* Port used by the processing-run coordinator to resolve the historical processing context
* for skipped documents. Supplied by Bootstrap via the startup context.
@@ -453,6 +461,7 @@ public final class GuiConfigurationEditorWorkspace {
this.miniRunLauncher = effectiveContext.miniRunLauncher();
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
@@ -462,6 +471,7 @@ public final class GuiConfigurationEditorWorkspace {
this::isSavedConfigurationReady,
this::applyBatchRunLockState,
() -> this.manualFileRenamePort,
() -> this.manualFileCopyPort,
() -> this.historicalDocumentContextPort,
this::editorSourceFolder,
this::editorTargetFolder);
@@ -495,6 +505,20 @@ public final class GuiConfigurationEditorWorkspace {
return manualFileRenamePort;
}
/**
* Liefert den Port für das manuelle Kopieren der Quelldatei eines bislang nicht
* erfolgreich verarbeiteten Dokuments ins Zielverzeichnis.
* <p>
* Wird von Bootstrap bereitgestellt. Der Verarbeitungslauf-Tab nutzt diesen Port,
* wenn der Benutzer für ein FAILED- oder SKIPPED-Dokument einen manuellen
* Zieldateinamen bestätigt.
*
* @return den {@link GuiManualFileCopyPort}; nie {@code null}
*/
public GuiManualFileCopyPort manualFileCopyPort() {
return manualFileCopyPort;
}
/**
* Returns the currently loaded configuration file path, or {@code null} when the
* editor has never loaded a file from disk. The processing-run tab uses this value to
@@ -7,6 +7,7 @@ import java.util.Set;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalDocumentContextPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
@@ -41,7 +42,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
* reset the persistence status of selected documents, and the
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI, and
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI,
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
* folder for documents that have not yet been successfully processed, and
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
* context for documents that were skipped in the current run.
* <p>
@@ -63,6 +66,7 @@ public record GuiStartupContext(
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
/**
@@ -85,6 +89,9 @@ public record GuiStartupContext(
* documents; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
* must not be {@code null}
* @param manualFileCopyPort bridge that copies a source file to the target folder for
* documents that have not yet been successfully processed;
* must not be {@code null}
* @param historicalDocumentContextPort bridge that resolves the historical processing context
* for skipped documents; must not be {@code null}
*/
@@ -115,6 +122,8 @@ public record GuiStartupContext(
"resetDocumentStatusPort must not be null");
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
"manualFileRenamePort must not be null");
manualFileCopyPort = Objects.requireNonNull(manualFileCopyPort,
"manualFileCopyPort must not be null");
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
"historicalDocumentContextPort must not be null");
}
@@ -157,6 +166,7 @@ public record GuiStartupContext(
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
}
@@ -192,6 +202,7 @@ public record GuiStartupContext(
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
}
@@ -227,7 +238,8 @@ public record GuiStartupContext(
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
rejectingManualFileRenamePort(), noOpHistoricalDocumentContextPort());
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -253,7 +265,13 @@ public record GuiStartupContext(
private static GuiManualFileRenamePort rejectingManualFileRenamePort() {
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileRenameFileSystemFailure(
"Kein Umbennennungs-Port in diesem Startkontext verfügbar.");
"Kein Umbenennungs-Port in diesem Startkontext verfügbar.");
}
private static GuiManualFileCopyPort rejectingManualFileCopyPort() {
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileCopyFileSystemFailure(
"Kein Kopier-Port in diesem Startkontext verfügbar.");
}
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
@@ -334,6 +352,7 @@ public record GuiStartupContext(
rejectingMiniRunLauncher(),
rejectingResetPort(),
rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort());
}
}
@@ -144,8 +144,18 @@ public final class FileNameEditorPane {
* <p>
* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet,
* der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}.
* Bei nicht editierbaren Status (FAILED_*, SKIPPED_*, reset-pending, kein SUCCESS)
* wird das Feld deaktiviert.
* Editierbarkeitsregeln:
* <ul>
* <li>{@code resetPending} → nicht editierbar.</li>
* <li>{@code SUCCESS} und {@code SKIPPED_ALREADY_PROCESSED} → editierbar, sofern
* ein bisher gespeicherter Zieldateiname vorliegt (Umbenennen einer existierenden
* Zieldatei).</li>
* <li>{@code FAILED_RETRYABLE}, {@code FAILED_PERMANENT} und
* {@code SKIPPED_FINAL_FAILURE} → editierbar; das Eingabefeld erlaubt die
* Eingabe eines manuellen Zieldateinamens auch dann, wenn (noch) kein
* Vorschlag oder gespeicherter Name vorliegt (Kopieren der Quelldatei
* mit manuellem Namen).</li>
* </ul>
*
* @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()}
* @param targetFolderPath Zielordner-Pfad für die Pfadlängen-Validierung; darf
@@ -160,7 +170,17 @@ public final class FileNameEditorPane {
this.aiProposal = stripPdfExtension(row.finalFileName());
this.lastSavedName = stripPdfExtension(row.effectiveFileName());
boolean editable = isRowEditable(row) && lastSavedName.isPresent();
boolean editable;
if (row.resetPending()) {
editable = false;
} else if (requiresExistingTargetForRename(row.status())) {
// Umbenennen einer existierenden Zieldatei: nur sinnvoll, wenn ein
// gespeicherter Name vorliegt.
editable = lastSavedName.isPresent();
} else {
// Manuelle Kopie: das Feld ist auch ohne gespeicherten Namen editierbar.
editable = isRowEditable(row);
}
this.selectionEditable = editable;
suppressValidation = true;
@@ -172,6 +192,18 @@ public final class FileNameEditorPane {
refreshUiState();
}
/**
* Liefert {@code true}, wenn die Zeile einen Status hat, bei dem die Editierung
* eine bestehende Zieldatei umbenennt (im Gegensatz zur Kopie der Quelldatei).
*
* @param status der aggregierte Abschlussstatus der Zeile
* @return {@code true} für SUCCESS und SKIPPED_ALREADY_PROCESSED; sonst {@code false}
*/
private static boolean requiresExistingTargetForRename(DocumentCompletionStatus status) {
return status == DocumentCompletionStatus.SUCCESS
|| status == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED;
}
/**
* Leert die Komponente und deaktiviert die Eingabe. Wird aufgerufen wenn keine Zeile
* selektiert ist.
@@ -407,11 +439,20 @@ public final class FileNameEditorPane {
return folderLength + separatorLength + baseName.length() + PDF_EXTENSION.length();
}
/**
* Liefert {@code true}, wenn die Zeile fachlich für eine manuelle Dateinamens-Aktion
* editierbar ist.
* <p>
* Editierbar sind alle nicht-resetpending-Zeilen unabhängig davon, ob die Aktion
* eine Zieldatei umbenennt (SUCCESS, SKIPPED_ALREADY_PROCESSED) oder die Quelldatei
* kopiert (FAILED_*, SKIPPED_FINAL_FAILURE). Die genaue Aktion wird vom Tab anhand
* des Status entschieden.
*
* @param row die Zeile, deren Editierbarkeit geprüft werden soll
* @return {@code true} wenn die Zeile editierbar ist; sonst {@code false}
*/
private static boolean isRowEditable(GuiBatchRunResultRow row) {
if (row.resetPending()) {
return false;
}
return row.status() == DocumentCompletionStatus.SUCCESS;
return !row.resetPending();
}
private static Optional<String> stripPdfExtension(Optional<String> fileNameWithExtension) {
@@ -22,6 +22,14 @@ 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.ManualFileCopyDocumentNotFound;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyFileSystemFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyInvalidState;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyNoOpIdenticalTarget;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyPersistenceFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameDocumentNotFound;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameFileSystemFailure;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameInvalidState;
@@ -195,6 +203,7 @@ public final class GuiBatchRunTab {
private final Runnable onRunStateChanged;
private final GuiBatchRunCoordinator coordinator;
private final Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier;
private final Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier;
private final Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier;
private final Supplier<Optional<Path>> sourceFolderSupplier;
private final Supplier<Optional<String>> targetFolderSupplier;
@@ -232,7 +241,10 @@ public final class GuiBatchRunTab {
* null sein
* @param onRunStateChanged Callback wenn das Lauf-Flag kippt; darf nicht
* null sein
* @param manualFileRenamePortSupplier Supplier für den manuellen Umbennennungs-Port;
* @param manualFileRenamePortSupplier Supplier für den manuellen Umbenennungs-Port;
* darf nicht null sein
* @param manualFileCopyPortSupplier Supplier für den manuellen Kopier-Port (für FAILED-/
* SKIPPED-Zeilen ohne existierende Zieldatei);
* darf nicht null sein
* @param historicalDocumentContextPortSupplier Supplier für den historischen Kontext-Port;
* darf nicht null sein
@@ -248,6 +260,7 @@ public final class GuiBatchRunTab {
BooleanSupplier savedConfigurationReadyCheck,
Runnable onRunStateChanged,
Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
Supplier<Optional<Path>> sourceFolderSupplier,
Supplier<Optional<String>> targetFolderSupplier) {
@@ -260,6 +273,8 @@ public final class GuiBatchRunTab {
this.onRunStateChanged = Objects.requireNonNull(onRunStateChanged, "onRunStateChanged must not be null");
this.manualFileRenamePortSupplier = Objects.requireNonNull(
manualFileRenamePortSupplier, "manualFileRenamePortSupplier must not be null");
this.manualFileCopyPortSupplier = Objects.requireNonNull(
manualFileCopyPortSupplier, "manualFileCopyPortSupplier must not be null");
this.historicalDocumentContextPortSupplier = Objects.requireNonNull(
historicalDocumentContextPortSupplier, "historicalDocumentContextPortSupplier must not be null");
this.sourceFolderSupplier = Objects.requireNonNull(
@@ -315,6 +330,7 @@ public final class GuiBatchRunTab {
savedConfigurationReadyCheck,
onRunStateChanged,
() -> GuiBatchRunTab::rejectingRename,
() -> GuiBatchRunTab::rejectingCopy,
() -> (cfgPath, fp) -> Optional.empty(),
Optional::empty,
Optional::empty);
@@ -322,7 +338,7 @@ public final class GuiBatchRunTab {
/**
* Rückwärtskompatible Variante mit Mini-Lauf- und Rücksetz-Fähigkeit, aber ohne
* manuellen Umbennennungs-Port und Ordner-Supplier.
* manuellen Umbenennungs-Port und Ordner-Supplier.
*
* @param launcherSupplier Supplier für den Batch-Lauf-Launcher;
* darf nicht null sein
@@ -348,6 +364,7 @@ public final class GuiBatchRunTab {
savedConfigurationReadyCheck,
onRunStateChanged,
() -> GuiBatchRunTab::rejectingRename,
() -> GuiBatchRunTab::rejectingCopy,
() -> (cfgPath, fp) -> Optional.empty(),
Optional::empty,
Optional::empty);
@@ -749,9 +766,19 @@ public final class GuiBatchRunTab {
}
/**
* Verarbeitet den Speichern-Callback des Dateiname-Editors. Läuft auf einem
* Hintergrund-Worker-Thread; das Ergebnis wird per {@code Platform.runLater}
* zurück auf den FX-Thread übertragen.
* Verarbeitet den Speichern-Callback des Dateiname-Editors. Läuft die eigentliche
* Aktion auf einem Hintergrund-Worker-Thread; das Ergebnis wird per
* {@code Platform.runLater} zurück auf den FX-Thread übertragen.
* <p>
* Die konkrete Aktion hängt vom Status der Zeile ab:
* <ul>
* <li>{@code SUCCESS} und {@code SKIPPED_ALREADY_PROCESSED} → bestehende Zieldatei
* wird umbenannt (via {@link GuiManualFileRenamePort}).</li>
* <li>{@code FAILED_RETRYABLE}, {@code FAILED_PERMANENT} und
* {@code SKIPPED_FINAL_FAILURE} → Quelldatei wird mit dem manuellen Namen
* ins Zielverzeichnis kopiert (via {@link GuiManualFileCopyPort}); der
* Dokumentstatus wechselt anschließend auf {@code SUCCESS}.</li>
* </ul>
*
* @param desiredBaseName der gewünschte Basisname ohne {@code .pdf}-Endung
*/
@@ -765,7 +792,23 @@ public final class GuiBatchRunTab {
showMessage(NO_SAVED_CONFIGURATION_HINT);
return;
}
GuiManualFileRenamePort port = manualFileRenamePortSupplier.get();
if (requiresCopyAction(row.status())) {
GuiManualFileCopyPort copyPort = manualFileCopyPortSupplier.get();
ManualFileCopyRequest copyRequest =
new ManualFileCopyRequest(row.fingerprint(), desiredBaseName);
LOG.info("Manuelle Dateikopie angefordert: {} (Status {}) → {}.pdf",
row.originalFileName(), row.status(), desiredBaseName);
renameExecutor.submit(() -> {
ManualFileCopyResult result = copyPort.copy(configPath, copyRequest);
Platform.runLater(() -> handleCopyResult(result, row));
});
return;
}
GuiManualFileRenamePort renamePort = manualFileRenamePortSupplier.get();
ManualFileRenameRequest request =
new ManualFileRenameRequest(row.fingerprint(), desiredBaseName);
@@ -773,11 +816,138 @@ public final class GuiBatchRunTab {
row.effectiveFileName().orElse("?"), desiredBaseName);
renameExecutor.submit(() -> {
ManualFileRenameResult result = port.rename(configPath, request);
ManualFileRenameResult result = renamePort.rename(configPath, request);
Platform.runLater(() -> handleRenameResult(result, row));
});
}
/**
* Liefert {@code true}, wenn der Status der Zeile dazu führt, dass die Quelldatei
* neu kopiert werden muss (statt eine bestehende Zieldatei umzubenennen).
*
* @param status der aggregierte Abschlussstatus der Zeile
* @return {@code true} bei FAILED_*- und SKIPPED_FINAL_FAILURE-Status
*/
private static boolean requiresCopyAction(DocumentCompletionStatus status) {
return switch (status) {
case FAILED_RETRYABLE,
FAILED_PERMANENT,
SKIPPED_FINAL_FAILURE -> true;
case SUCCESS,
SKIPPED_ALREADY_PROCESSED -> false;
};
}
/**
* Verarbeitet das Ergebnis einer manuellen Dateikopie auf dem FX-Thread.
* <p>
* Bei Erfolg wird die Zeile in der Tabelle so aktualisiert, dass sie als
* {@code SUCCESS} mit dem neu vergebenen Zieldateinamen erscheint und der
* Detailbereich anschließend wie für eine erfolgreich verarbeitete Zeile bedienbar
* ist (Umbenennen statt erneutem Kopieren).
*
* @param result das Ergebnis des Use-Case-Aufrufs
* @param row die Zeile, für die die Kopie angefordert wurde
*/
private void handleCopyResult(ManualFileCopyResult result, GuiBatchRunResultRow row) {
switch (result) {
case ManualFileCopySuccess success -> {
LOG.info("Manuelle Dateikopie erfolgreich: {} → {} (Suffix: {})",
row.originalFileName(), success.appliedFileName(),
success.conflictSuffixApplied());
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, success.appliedFileName());
currentlySelectedRow = updatedRow;
fileNameEditor.clearDirtyState();
upsertResultRowByFingerprint(updatedRow);
String targetFolder = targetFolderSupplier.get().orElse("");
fileNameEditor.loadSelection(updatedRow, targetFolder);
String msg = "Datei kopiert und gespeichert: " + success.appliedFileName();
if (success.conflictSuffixApplied()) {
msg += " (Suffix wegen Namenskonflikt angehängt)";
}
showMessage(msg);
refreshAggregateCountersFromItems();
}
case ManualFileCopyNoOpIdenticalTarget noOp -> {
LOG.info("Manuelle Dateikopie: identische Zieldatei {} bereits vorhanden kein Schreibvorgang.",
noOp.existingFileName());
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, noOp.existingFileName());
currentlySelectedRow = updatedRow;
fileNameEditor.clearDirtyState();
upsertResultRowByFingerprint(updatedRow);
String targetFolder = targetFolderSupplier.get().orElse("");
fileNameEditor.loadSelection(updatedRow, targetFolder);
showMessage("Identische Datei bereits vorhanden Status auf SUCCESS gesetzt");
refreshAggregateCountersFromItems();
}
case ManualFileCopyDocumentNotFound notFound -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", notFound.reason());
showMessage("Fehler: Dokument nicht gefunden " + notFound.reason());
}
case ManualFileCopyInvalidState invalidState -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", invalidState.reason());
showMessage("Fehler: Ungültiger Dokumentstatus " + invalidState.reason());
}
case ManualFileCopyFileSystemFailure fsFail -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", fsFail.message());
showMessage("Dateisystemfehler: " + fsFail.message());
}
case ManualFileCopyPersistenceFailure persistFail -> {
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", persistFail.message());
showMessage("Persistenzfehler (Zielkopie wurde zurückgerollt): "
+ persistFail.message());
}
}
}
/**
* Baut eine neue Zeilen-Sicht für ein Dokument, das per manueller Dateikopie auf
* {@code SUCCESS} gehoben wurde. Status, korrigierter Dateiname und das Zurücksetzen
* der Fehlermeldung machen die Zeile danach im Detailbereich behandelbar wie eine
* regulär erfolgreich verarbeitete Zeile.
*
* @param previous die ursprüngliche Zeile (FAILED- oder SKIPPED-Status)
* @param appliedFileName der finale Zieldateiname inklusive Endung
* @return eine aktualisierte Zeile mit Status {@code SUCCESS}
*/
private static GuiBatchRunResultRow buildSuccessRowAfterCopy(
GuiBatchRunResultRow previous, String appliedFileName) {
return new GuiBatchRunResultRow(
previous.originalFileName(),
previous.fingerprint(),
DocumentCompletionStatus.SUCCESS,
Optional.of(appliedFileName),
Optional.empty(),
previous.resolvedDate(),
previous.aiReasoning(),
Optional.empty(),
previous.processingDuration(),
false,
Optional.empty());
}
/**
* Aktualisiert die aggregierten Zähler (Erfolg, Fehler, Übersprungen) anhand der
* aktuellen Tabellenzeilen. Wird nach Statusänderungen einzelner Zeilen außerhalb
* eines Laufs aufgerufen, damit die Anzeige konsistent bleibt.
*/
private void refreshAggregateCountersFromItems() {
int success = 0;
int failed = 0;
int skipped = 0;
for (GuiBatchRunResultRow item : resultItems) {
switch (item.status()) {
case SUCCESS -> success++;
case FAILED_RETRYABLE, FAILED_PERMANENT -> failed++;
case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> skipped++;
}
}
this.successCount = success;
this.failedCount = failed;
this.skippedCount = skipped;
updateCounterLabel();
}
/**
* Verarbeitet das Ergebnis einer manuellen Dateiumbenennung auf dem FX-Thread.
*
@@ -1304,7 +1474,13 @@ public final class GuiBatchRunTab {
private static ManualFileRenameResult rejectingRename(
Path p, ManualFileRenameRequest req) {
return new ManualFileRenameFileSystemFailure(
"Kein Umbennennungs-Port in diesem Startkontext verfügbar.");
"Kein Umbenennungs-Port in diesem Startkontext verfügbar.");
}
private static ManualFileCopyResult rejectingCopy(
Path p, ManualFileCopyRequest req) {
return new ManualFileCopyFileSystemFailure(
"Kein Kopier-Port in diesem Startkontext verfügbar.");
}
// -------------------------------------------------------------------------
@@ -0,0 +1,48 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
/**
* Inbound-Brücke für die manuelle Kopie der Quelldatei eines bislang nicht erfolgreich
* verarbeiteten Dokuments aus der GUI.
* <p>
* Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen, wenn der
* Benutzer für ein nicht erfolgreich verarbeitetes Dokument (Status {@code FAILED_*} oder
* {@code SKIPPED_FINAL_FAILURE}) einen manuellen Zieldateinamen bestätigt. Der Port
* kapselt das vollständige Wiring (Konfigurationsauflösung, Use-Case-Konstruktion und
* Ausführung), sodass der GUI-Adapter keine Kenntnis von infrastrukturellen
* Implementierungsdetails benötigt.
*
* <h2>Threadingmodell</h2>
* <p>
* Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung ist
* synchron und blockierend: Sie kehrt erst zurück, wenn die Kopie abgeschlossen oder
* fehlgeschlagen ist. Aufrufer aus dem GUI-Layer müssen den Aufruf daher auf einem
* Hintergrund-Worker-Thread ausführen und das Ergebnis anschließend per
* {@code Platform.runLater} auf den JavaFX-Application-Thread zurückführen.
*
* <h2>Exception-Vertrag</h2>
* <p>
* Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete
* Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileCopyResult}
* zurückgegeben werden.
*/
@FunctionalInterface
public interface GuiManualFileCopyPort {
/**
* Kopiert die Quelldatei eines Dokuments mit benutzerdefiniertem Zieldateinamen ins
* Zielverzeichnis und aktualisiert den Dokument-Stammsatz auf {@code SUCCESS}.
*
* @param configFilePath Pfad zur {@code .properties}-Datei, die SQLite-Datenbank,
* Quell- und Zielordner beschreibt; darf nicht {@code null} sein;
* muss existieren und lesbar sein
* @param request die Kopieranfrage mit Fingerprint und gewünschtem
* Basisdateinamen; darf nicht {@code null} sein
* @return das Ergebnis der Kopieroperation; nie {@code null}
*/
ManualFileCopyResult copy(Path configFilePath, ManualFileCopyRequest request);
}