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:
+24
@@ -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
|
||||
|
||||
+22
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+48
-7
@@ -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) {
|
||||
|
||||
+184
-8
@@ -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.");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+48
@@ -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);
|
||||
}
|
||||
+105
-4
@@ -332,23 +332,124 @@ class FileNameEditorPaneTest {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status FAILED → deaktiviert
|
||||
// Editierbarkeit nach Status
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadSelection_failedStatus_disablesTextField() throws Exception {
|
||||
void loadSelection_failedPermanentStatus_enablesTextFieldForManualCopy() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// FAILED-Zeile ohne KI-Vorschlag und ohne gespeicherten Namen: muss
|
||||
// dennoch editierbar sein, damit der Benutzer einen manuellen
|
||||
// Zieldateinamen für die Kopie eingeben kann.
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertTrue(pane.textField().isDisable(),
|
||||
"FAILED-Status soll TextField deaktivieren");
|
||||
assertFalse(pane.textField().isDisable(),
|
||||
"FAILED-Status soll TextField für manuelle Kopie aktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_failedRetryableStatus_enablesTextFieldForManualCopy() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.FAILED_RETRYABLE,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertFalse(pane.textField().isDisable(),
|
||||
"FAILED_RETRYABLE soll TextField für manuelle Kopie aktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_skippedFinalFailureStatus_enablesTextFieldForManualCopy() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// SKIPPED_FINAL_FAILURE: Zieldatei existiert nicht → manuelle Kopie zulässig
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.SKIPPED_FINAL_FAILURE,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertFalse(pane.textField().isDisable(),
|
||||
"SKIPPED_FINAL_FAILURE soll TextField für manuelle Kopie aktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_skippedAlreadyProcessedStatus_enablesTextFieldForRename() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// SKIPPED_ALREADY_PROCESSED: Zieldatei existiert (lastSavedName gesetzt) → Rename
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
|
||||
Optional.of("2026-01-01 - Bestehend.pdf"),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertFalse(pane.textField().isDisable(),
|
||||
"SKIPPED_ALREADY_PROCESSED soll TextField für Umbenennung aktivieren");
|
||||
assertEquals("2026-01-01 - Bestehend", pane.textField().getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_skippedAlreadyProcessed_withoutSavedName_disablesTextField() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
// SKIPPED_ALREADY_PROCESSED ohne lastSavedName: keine bestehende Zieldatei zum Umbenennen
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertTrue(pane.textField().isDisable(),
|
||||
"SKIPPED_ALREADY_PROCESSED ohne gespeicherten Namen soll TextField deaktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSelection_resetPending_disablesTextField() throws Exception {
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.SUCCESS,
|
||||
Optional.of("2026-01-01 - X.pdf"),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1), true);
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
assertTrue(pane.textField().isDisable(),
|
||||
"resetPending soll TextField unabhängig vom Status deaktivieren");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void enter_whenFailedStatus_triggersSaveCallback() throws Exception {
|
||||
AtomicReference<String> capturedName = new AtomicReference<>();
|
||||
runOnFx(() -> {
|
||||
FileNameEditorPane pane = new FileNameEditorPane();
|
||||
pane.setOnSaveRequested(capturedName::set);
|
||||
// FAILED-Zeile ohne gespeicherten Namen: User tippt manuellen Namen ein
|
||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
|
||||
Duration.ofMillis(1));
|
||||
pane.loadSelection(row, "C:\\target");
|
||||
pane.textField().setText("2026-04-27 - Manuell benannt");
|
||||
pane.textField().getOnKeyPressed().handle(
|
||||
new javafx.scene.input.KeyEvent(
|
||||
javafx.scene.input.KeyEvent.KEY_PRESSED,
|
||||
"", "", KeyCode.ENTER, false, false, false, false));
|
||||
});
|
||||
assertEquals("2026-04-27 - Manuell benannt", capturedName.get(),
|
||||
"Enter soll Save-Callback auch bei FAILED-Status mit manueller Eingabe auslösen");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user