+ * Supplied by Bootstrap. The processing-run tab can retrieve this port to delegate
+ * manual rename actions without holding a direct reference to Bootstrap internals.
+ *
+ * @return the {@link GuiManualFileRenamePort}; never {@code null}
+ */
+ public GuiManualFileRenamePort manualFileRenamePort() {
+ return manualFileRenamePort;
+ }
+
/**
* 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
@@ -486,6 +509,40 @@ public final class GuiConfigurationEditorWorkspace {
return editorState.hasLoadedFileSnapshot();
}
+ /**
+ * Liefert den im Editor eingestellten Quellordner als {@link Path}, sofern ein
+ * nicht-leerer Wert vorliegt. Wird vom Verarbeitungslauf-Tab genutzt um die
+ * Quelldatei für die PDF-Vorschau zu lokalisieren.
+ *
+ * @return den Quellordner-Pfad oder ein leeres Optional
+ */
+ private java.util.Optional
* 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.
@@ -54,7 +58,8 @@ public record GuiStartupContext(
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
- GuiResetDocumentStatusPort resetDocumentStatusPort) {
+ GuiResetDocumentStatusPort resetDocumentStatusPort,
+ GuiManualFileRenamePort manualFileRenamePort) {
/**
* Creates a fully wired startup context.
@@ -74,6 +79,8 @@ public record GuiStartupContext(
* documents; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
* documents; must not be {@code null}
+ * @param manualFileRenamePort bridge that renames a target file manually from the GUI;
+ * must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -100,11 +107,53 @@ public record GuiStartupContext(
"miniRunLauncher must not be null");
resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
"resetDocumentStatusPort must not be null");
+ manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
+ "manualFileRenamePort must not be null");
}
/**
- * Backward-compatible constructor that fills the mini-run launcher and reset port
- * with no-op implementations.
+ * Backward-compatible constructor that fills the manual-rename port with a no-op
+ * implementation.
+ *
+ * @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}
+ * @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(
+ GuiConfigurationEditorState initialState,
+ Optional
* Preserves existing callers that were written before the processing-run tab was added.
*
@@ -167,7 +216,8 @@ public record GuiStartupContext(
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService,
- rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort());
+ rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
+ rejectingManualFileRenamePort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -190,6 +240,12 @@ 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.");
+ }
+
/**
* Creates a blank startup context with no-op implementations for all ports and services.
*
@@ -262,6 +318,7 @@ public record GuiStartupContext(
noOpCorrectionService,
noOpBatchRunLauncher,
rejectingMiniRunLauncher(),
- rejectingResetPort());
+ rejectingResetPort(),
+ rejectingManualFileRenamePort());
}
}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPane.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPane.java
new file mode 100644
index 0000000..1fe943d
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPane.java
@@ -0,0 +1,432 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
+
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextField;
+import javafx.scene.input.KeyCode;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
+
+/**
+ * Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
+ * Ergebnis-Zeile.
+ *
+ * Die Komponente kapselt Eingabefeld, feste Dateiendung, Validierungsanzeige sowie die
+ * Schaltflächen „Dateiname übernehmen" und „Zurücksetzen auf KI-Vorschlag". Sie kennt
+ * drei Zustände gemäß fachlicher Spezifikation:
+ *
+ * Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen werden.
+ * Die tatsächliche Speicher-Operation ist in der Verantwortung des aufrufenden Tabs und
+ * läuft dort auf einem Hintergrund-Worker-Thread.
+ */
+public final class FileNameEditorPane {
+
+ /** Feste PDF-Erweiterung für Zieldateien. */
+ public static final String PDF_EXTENSION = ".pdf";
+
+ /** Windows-Maximal-Pfadlänge (MAX_PATH = 260 inkl. Null-Terminator = 259 nutzbar). */
+ public static final int MAX_WINDOWS_PATH_LENGTH = 259;
+
+ private static final Set
+ * 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.
+ *
+ * @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()}
+ * @param targetFolderPath Zielordner-Pfad für die Pfadlängen-Validierung; darf
+ * {@code null} sein (wird als leer behandelt)
+ */
+ public void loadSelection(GuiBatchRunResultRow row, String targetFolderPath) {
+ this.targetFolderPath = targetFolderPath == null ? "" : targetFolderPath;
+ if (row == null) {
+ clearSelection();
+ return;
+ }
+ this.aiProposal = stripPdfExtension(row.finalFileName());
+ this.lastSavedName = stripPdfExtension(row.effectiveFileName());
+
+ boolean editable = isRowEditable(row) && lastSavedName.isPresent();
+ this.selectionEditable = editable;
+
+ suppressValidation = true;
+ try {
+ textField.setText(lastSavedName.orElse(""));
+ } finally {
+ suppressValidation = false;
+ }
+ refreshUiState();
+ }
+
+ /**
+ * Leert die Komponente und deaktiviert die Eingabe. Wird aufgerufen wenn keine Zeile
+ * selektiert ist.
+ */
+ public void clearSelection() {
+ this.aiProposal = Optional.empty();
+ this.lastSavedName = Optional.empty();
+ this.selectionEditable = false;
+ suppressValidation = true;
+ try {
+ textField.setText("");
+ } finally {
+ suppressValidation = false;
+ }
+ refreshUiState();
+ }
+
+ /**
+ * Setzt den Textfeldinhalt auf den zuletzt gespeicherten Namen zurück. Äquivalent zum
+ * Drücken der Escape-Taste im Textfeld.
+ */
+ public void discardChanges() {
+ suppressValidation = true;
+ try {
+ textField.setText(lastSavedName.orElse(""));
+ } finally {
+ suppressValidation = false;
+ }
+ refreshUiState();
+ }
+
+ /**
+ * Setzt den Textfeldinhalt auf den KI-Vorschlag zurück. Es erfolgt kein
+ * Speichervorgang – der Benutzer kann anschließend über „Dateiname übernehmen"
+ * bestätigen.
+ */
+ public void resetToAiProposal() {
+ if (aiProposal.isEmpty()) {
+ return;
+ }
+ suppressValidation = true;
+ try {
+ textField.setText(aiProposal.get());
+ } finally {
+ suppressValidation = false;
+ }
+ refreshUiState();
+ }
+
+ /**
+ * Aktiviert oder deaktiviert die gesamte Komponente. Während eines laufenden Batch-Laufs
+ * soll die Komponente deaktiviert sein.
+ *
+ * @param enabled {@code true} wenn Bedienung erlaubt ist
+ */
+ public void setEnabled(boolean enabled) {
+ this.globalEnabled = enabled;
+ refreshUiState();
+ }
+
+ /**
+ * Liefert {@code true} wenn die aktuelle Texteingabe vom letzten gespeicherten Namen
+ * abweicht.
+ *
+ * @return ob ungespeicherte Änderungen im Textfeld vorliegen
+ */
+ public boolean isDirty() {
+ if (!selectionEditable) {
+ return false;
+ }
+ String current = textField.getText() == null ? "" : textField.getText();
+ String saved = lastSavedName.orElse("");
+ return !current.equals(saved);
+ }
+
+ /**
+ * Liefert {@code true} wenn für die aktuelle Zeile ein KI-Vorschlag vorliegt.
+ *
+ * @return ob ein KI-Vorschlag existiert
+ */
+ public boolean hasAiProposal() {
+ return aiProposal.isPresent();
+ }
+
+ /**
+ * Liefert {@code true} wenn für die aktuelle Zeile ein zuletzt gespeicherter Name
+ * existiert.
+ *
+ * @return ob ein letzter gespeicherter Name existiert
+ */
+ public boolean hasLastSaved() {
+ return lastSavedName.isPresent();
+ }
+
+ /**
+ * Aktualisiert intern den letzten gespeicherten Namen. Typisch nach erfolgreichem
+ * Speichervorgang im Tab (ohne erneut {@link #loadSelection(GuiBatchRunResultRow, String)}
+ * aufzurufen).
+ *
+ * @param newLastSavedName neuer letzter gespeicherter Name ohne {@code .pdf}; darf
+ * {@code null} sein
+ */
+ public void updateLastSavedName(String newLastSavedName) {
+ this.lastSavedName = newLastSavedName == null || newLastSavedName.isBlank()
+ ? Optional.empty()
+ : Optional.of(newLastSavedName);
+ suppressValidation = true;
+ try {
+ textField.setText(lastSavedName.orElse(""));
+ } finally {
+ suppressValidation = false;
+ }
+ refreshUiState();
+ }
+
+ // --- Test-Accessoren ------------------------------------------------------
+
+ /** Visible for tests. */
+ TextField textField() {
+ return textField;
+ }
+
+ /** Visible for tests. */
+ Label validationLabel() {
+ return validationLabel;
+ }
+
+ /** Visible for tests. */
+ Button saveButton() {
+ return saveButton;
+ }
+
+ /** Visible for tests. */
+ Button resetButton() {
+ return resetButton;
+ }
+
+ // --- Interne Helfer -------------------------------------------------------
+
+ private void fireSaveRequest() {
+ if (saveButton.isDisabled()) {
+ return;
+ }
+ String current = textField.getText() == null ? "" : textField.getText();
+ onSaveRequested.accept(current);
+ }
+
+ private void refreshUiState() {
+ boolean enabled = selectionEditable && globalEnabled;
+ textField.setDisable(!enabled);
+ resetButton.setDisable(!enabled || aiProposal.isEmpty());
+
+ if (!enabled) {
+ // Validierung und Speichern-Button unterdrücken, Rahmen neutral.
+ validationLabel.setVisible(false);
+ validationLabel.setManaged(false);
+ textField.setStyle("");
+ saveButton.setDisable(true);
+ return;
+ }
+
+ String current = textField.getText() == null ? "" : textField.getText();
+ Optional
+ * Die Tabellenspalte „Neuer Dateiname" bindet an diesen Wert.
+ *
+ * @return den aktuell anzuzeigenden Zieldateinamen; leer wenn kein Name vorliegt
+ */
+ public Optional
- * The tab encapsulates all UI for starting, observing, and stopping a batch run from
- * 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.
+ * Zweiter Haupt-Tab des JavaFX-Editorfensters: die Live-Verarbeitungslauf-Ansicht.
+ *
+ * Der Tab kapselt die gesamte UI zum Starten, Beobachten und Abbrechen eines
+ * Batch-Laufs. Er arbeitet mit einem {@link GuiBatchRunCoordinator} zusammen, der
+ * den Hintergrund-Worker-Thread besitzt und Fortschritts-Callbacks auf dem JavaFX
+ * Application Thread zurückmeldet.
+ *
+ * Nach einem Lauf kann der Benutzer eine oder mehrere Zeilen auswählen und
+ * entweder „Erneut verarbeiten" oder „Status zurücksetzen" auslösen. Zusätzlich
+ * kann der Benutzer den von der KI vorgeschlagenen Dateinamen für erfolgreich
+ * verarbeitete Dokumente direkt in der GUI bearbeiten und speichern.
*
*
- * All public methods of this class must be invoked on the JavaFX Application Thread. The
- * class is not thread-safe; the coordinator is responsible for dispatching background
- * events onto the FX thread before calling back into the tab.
+ * Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
+ * werden. Die Klasse ist nicht thread-sicher; der Coordinator ist verantwortlich
+ * dafür, Hintergrundereignisse vor dem Callback auf den FX-Thread zu übertragen.
*/
public final class GuiBatchRunTab {
private static final Logger LOG = LogManager.getLogger(GuiBatchRunTab.class);
- /** Spec: "Datei auswählen für Details". Shown in the detail pane before the first row is selected. */
+ /** Platzhalter im Detailbereich vor der ersten Zeilenselektion. */
static final String DETAIL_PLACEHOLDER = "Datei auswählen für Details";
- /** Spec: hint shown when no AI reasoning is available for the selected row. */
+ /** Hinweis wenn kein KI-Reasoning für die selektierte Zeile vorliegt. */
static final String NO_REASONING_TEXT = "Für diesen Eintrag liegt kein KI-Reasoning vor.";
- /** Spec: hint shown when the start button is pressed against an empty source folder. */
+ /** Hinweis beim Start gegen einen leeren Quellordner. */
static final String EMPTY_SOURCE_FOLDER_HINT =
"Keine verarbeitbaren Dateien im Quellordner gefunden";
- /** Spec: hint shown when a second start attempt is made while a run is active. */
+ /** Hinweis wenn ein zweiter Startversuch bei laufendem Lauf ausgelöst wird. */
static final String ALREADY_RUNNING_HINT = "Ein Verarbeitungslauf ist bereits aktiv.";
- /** Shown when the DB-reset before "Erneut verarbeiten" failed for all selected documents. */
+ /** Angezeigt wenn der DB-Reset vor „Erneut verarbeiten" für alle Dokumente scheiterte. */
static final String REPROCESS_RESET_FAILED_HINT =
"Fehler: Status-Reset fehlgeschlagen – Mini-Lauf wurde nicht gestartet.";
- /** Spec: German startup error shown when the saved configuration is unusable. */
+ /** Hinweis wenn keine gespeicherte Konfiguration vorliegt. */
static final String NO_SAVED_CONFIGURATION_HINT =
"Bitte speichern Sie die Konfiguration, bevor ein Verarbeitungslauf gestartet wird.";
- /** Icon-to-placeholder rendering for empty columns in failure and skip rows. */
+ /** Platzhalter in leeren Tabellenzellen. */
static final String EMPTY_CELL_TEXT = "\u2014"; // —
private static final String TAB_TITLE = "Verarbeitungslauf";
@@ -116,46 +132,49 @@ public final class GuiBatchRunTab {
private static final double PROGRESS_BAR_PREF_HEIGHT = 20;
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 static final double SPLIT_DIVIDER_POSITION = 0.6;
+ private static final int DETAIL_AREA_ROW_COUNT = 4;
private final Tab tab = new Tab(TAB_TITLE);
private final ProgressBar progressBar = new ProgressBar(0);
private final Label counterLabel = new Label("0 / 0 Dateien");
private final TableView
- * When no run is active the call has no effect. Cancellation is honoured between
- * candidates — the currently processed candidate always finishes first.
+ * Fordert Soft-Stop-Abbruch des aktuell laufenden Batch-Laufs an.
+ * Wenn kein Lauf aktiv ist, hat der Aufruf keinen Effekt.
*/
public void requestCancellation() {
coordinator.requestCancellation();
cancelButton.setDisable(true);
}
+ /**
+ * Gibt an ob im Dateiname-Editor ungespeicherte Änderungen vorliegen.
+ *
+ * @return {@code true} wenn der Editor einen Dirty-State hat
+ */
+ public boolean hasUnsavedFilenameEdits() {
+ return fileNameEditor.isDirty();
+ }
+
+ /**
+ * Zeigt ggf. einen Bestätigungsdialog für das Verwerfen ungespeicherter
+ * Dateinamen-Änderungen und gibt zurück ob fortgefahren werden darf.
+ * Wenn kein Dirty-State vorliegt, wird immer {@code true} zurückgeliefert.
+ *
+ * @return {@code true} wenn fortgefahren werden darf (Benutzer hat „Verwerfen"
+ * gewählt oder kein Dirty-State vorlag)
+ */
+ public boolean confirmDiscardUnsavedFilenameEdits() {
+ if (!fileNameEditor.isDirty()) {
+ return true;
+ }
+ return askDiscardFilenameChanges();
+ }
+
// -------------------------------------------------------------------------
- // Package-private accessors for tests
+ // Paket-private Accessor für Tests
// -------------------------------------------------------------------------
/** Visible for tests. */
@@ -339,8 +449,14 @@ public final class GuiBatchRunTab {
/** Visible for tests. */
CheckBox masterCheckBox() { return masterCheckBox; }
+ /** Visible for tests. */
+ FileNameEditorPane fileNameEditor() { return fileNameEditor; }
+
+ /** Visible for tests. */
+ PdfPreviewPane pdfPreview() { return pdfPreview; }
+
// -------------------------------------------------------------------------
- // Layout builders
+ // Layout-Aufbau
// -------------------------------------------------------------------------
private BorderPane buildContent() {
@@ -374,24 +490,41 @@ public final class GuiBatchRunTab {
tableScroll.setId("batch-run-result-scroll");
resultTable.setMinHeight(LIST_MIN_HEIGHT);
+ // Detailbereich: KI-Begründung oben (kompakt), darunter Dateiname-Editor,
+ // darunter PDF-Vorschau (nimmt verbleibenden Platz)
+ VBox detailBox = buildDetailPane();
+
+ SplitPane splitPane = new SplitPane(tableScroll, detailBox);
+ splitPane.setId("batch-run-split-pane");
+ splitPane.setDividerPositions(SPLIT_DIVIDER_POSITION);
+ SplitPane.setResizableWithParent(detailBox, true);
+
+ return splitPane;
+ }
+
+ private VBox buildDetailPane() {
+ // --- KI-Begründung (kompakt oben) ---
detailArea.setId("batch-run-detail");
detailArea.setEditable(false);
detailArea.setWrapText(true);
- detailArea.setMinHeight(DETAIL_AREA_MIN_HEIGHT);
+ detailArea.setPrefRowCount(DETAIL_AREA_ROW_COUNT);
detailArea.setMinWidth(DETAIL_PANE_MIN_WIDTH);
Label detailTitle = new Label("KI-Begründung");
detailTitle.setStyle("-fx-font-weight: bold;");
+ VBox reasoningBox = new VBox(SECONDARY_SPACING / 2.0, detailTitle, detailArea);
- VBox detailBox = new VBox(SECONDARY_SPACING / 2.0, detailTitle, detailArea);
+ // --- Dateiname-Editor (Mitte) ---
+ Region editorNode = fileNameEditor.getNode();
+
+ // --- PDF-Vorschau (Restplatz unten) ---
+ Region previewNode = pdfPreview.getNode();
+ VBox.setVgrow(previewNode, Priority.ALWAYS);
+
+ VBox detailBox = new VBox(SECONDARY_SPACING / 2.0, reasoningBox, editorNode, previewNode);
detailBox.setPadding(new Insets(0, 0, 0, SECONDARY_SPACING));
detailBox.setMinWidth(DETAIL_PANE_MIN_WIDTH);
- VBox.setVgrow(detailArea, Priority.ALWAYS);
-
- HBox centerSplit = new HBox(tableScroll, detailBox);
- HBox.setHgrow(tableScroll, Priority.ALWAYS);
- HBox.setHgrow(detailBox, Priority.NEVER);
- return centerSplit;
+ return detailBox;
}
private void configureResultTable() {
@@ -400,7 +533,7 @@ public final class GuiBatchRunTab {
resultTable.setPlaceholder(new Label("Noch kein Verarbeitungslauf gestartet."));
resultTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
- // Checkbox column with master-checkbox header
+ // Checkbox-Spalte mit Master-Checkbox-Kopf
TableColumn
- * Must be called on the JavaFX Application Thread.
+ * Muss auf dem JavaFX Application Thread aufgerufen werden.
*
- * @param newRow the new row; must not be {@code null}
+ * @param newRow die neue Zeile; darf nicht null sein
*/
void upsertResultRowByFingerprint(GuiBatchRunResultRow newRow) {
for (int i = 0; i < resultItems.size(); i++) {
if (resultItems.get(i).fingerprint().equals(newRow.fingerprint())) {
resultItems.set(i, newRow);
+ // Falls die aktuell selektierte Zeile aktualisiert wurde, Referenz erneuern
+ if (currentlySelectedRow != null
+ && currentlySelectedRow.fingerprint().equals(newRow.fingerprint())) {
+ currentlySelectedRow = newRow;
+ }
return;
}
}
@@ -801,7 +1127,7 @@ public final class GuiBatchRunTab {
}
// -------------------------------------------------------------------------
- // UI state management
+ // UI-Zustandsverwaltung
// -------------------------------------------------------------------------
private void showMessage(String message) {
@@ -836,13 +1162,10 @@ 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();
}
@@ -864,7 +1187,7 @@ public final class GuiBatchRunTab {
}
// -------------------------------------------------------------------------
- // Static helpers
+ // Statische Helfer
// -------------------------------------------------------------------------
private static String statusColor(DocumentCompletionStatus status) {
@@ -891,7 +1214,7 @@ public final class GuiBatchRunTab {
builder.append('\n').append(GuiBatchRunResultRow.RESET_PENDING_LABEL);
return builder.toString();
}
- row.finalFileName()
+ row.effectiveFileName()
.ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
row.resolvedDate()
.ifPresent(date -> builder.append("Datum: ")
@@ -927,8 +1250,14 @@ public final class GuiBatchRunTab {
fingerprints.size(), Set.of(), failures);
}
+ private static ManualFileRenameResult rejectingRename(
+ Path p, ManualFileRenameRequest req) {
+ return new ManualFileRenameFileSystemFailure(
+ "Kein Umbennennungs-Port in diesem Startkontext verfügbar.");
+ }
+
// -------------------------------------------------------------------------
- // Coordinator listener
+ // Coordinator-Listener
// -------------------------------------------------------------------------
private final class CoordinatorListener implements GuiBatchRunCoordinator.Listener {
@@ -951,8 +1280,6 @@ public final class GuiBatchRunTab {
@Override
public void onDocumentCompleted(GuiBatchRunResultRow row) {
- // For mini-runs, update rows in-place so reset-pending markers are replaced
- // with the real processing result. For regular runs, always append.
if (activeRunIsMiniRun) {
miniRunCompletedFingerprints.add(row.fingerprint());
upsertResultRowByFingerprint(row);
@@ -975,9 +1302,6 @@ public final class GuiBatchRunTab {
public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
runningProperty.set(false);
if (activeRunIsMiniRun) {
- // Only synthesize FAILED_PERMANENT rows for missing source files when the
- // mini-run actually completed. On soft-stop the non-started reset-pending
- // rows stay as-is per spec ("wartet auf nächsten Lauf").
if (outcome.successfullyStarted() && outcome.batchCompletedNormally()) {
synthesizeMissingSourceFileRows();
}
@@ -988,8 +1312,6 @@ public final class GuiBatchRunTab {
appendSummary(outcome);
updateButtonStates();
notifyRunStateChanged();
- // Lokale Zähler verwenden, nicht RunSummary – synthetisierte Fehlzeilen
- // (fehlende Quelldatei) sind nur im lokalen failedCount erfasst.
LOG.info("GUI-Verarbeitungslauf: Lauf beendet. successfullyStarted={}, completed={}, "
+ "erfolgreich={}, fehlgeschlagen={}, übersprungen={}.",
outcome.successfullyStarted(), outcome.batchCompletedNormally(),
@@ -997,10 +1319,9 @@ public final class GuiBatchRunTab {
}
/**
- * Detects fingerprints that were selected at mini-run start but did not receive
- * a completion event – this happens when the source file has been moved or
- * deleted between selection and processing. Replaces the corresponding
- * reset-pending rows with a permanent-failure marker carrying a German message.
+ * Erkennt Fingerabdrücke, die beim Mini-Lauf-Start ausgewählt waren, aber kein
+ * Completion-Callback erhalten haben. Ersetzt Reset-Pending-Marker durch
+ * permanente Fehlerzeilen.
*/
private void synthesizeMissingSourceFileRows() {
for (Map.Entry
+ * Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen,
+ * wenn der Benutzer einen geänderten Dateinamen 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.
+ *
+ *
+ * Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung
+ * ist synchron und blockierend: Sie kehrt erst zurück, wenn die Umbenennung
+ * 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.
+ *
+ *
+ * Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete
+ * Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileRenameResult}
+ * zurückgegeben werden.
+ */
+@FunctionalInterface
+public interface GuiManualFileRenamePort {
+
+ /**
+ * Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
+ *
+ * @param configFilePath Pfad zur {@code .properties}-Datei, die die SQLite-Datenbank
+ * und den Zielordner beschreibt; darf nicht {@code null} sein;
+ * muss existieren und lesbar sein
+ * @param request die Umbenennungsanfrage mit Fingerprint und gewünschtem
+ * Basisdateinamen; darf nicht {@code null} sein
+ * @return das Ergebnis der Umbenennung; nie {@code null}
+ */
+ ManualFileRenameResult rename(Path configFilePath, ManualFileRenameRequest request);
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java
new file mode 100644
index 0000000..7975cf8
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/PdfPreviewPane.java
@@ -0,0 +1,390 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import com.dlsc.pdfviewfx.PDFView;
+import javafx.application.Platform;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.ProgressIndicator;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.StackPane;
+import javafx.scene.layout.VBox;
+
+/**
+ * Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
+ *
+ * Die Komponente zeigt die Seiten einer PDF-Datei mit Seitennavigation an.
+ * Das Laden erfolgt auf einem Hintergrund-Worker-Thread; UI-Updates laufen
+ * ausschließlich über den JavaFX Application Thread.
+ *
+ * PDFView übernimmt intern das Rendern und die Darstellung. Diese Komponente
+ * steuert Laden, Fehlerbehandlung und den Ladeindikator.
+ *
+ * Beim Selektionswechsel wird eine neue Lade-Anforderung ausgelöst. Es gilt das
+ * Prinzip „Latest Preview Request Wins": Veraltete Lade-Ergebnisse werden
+ * verworfen, sobald eine neue Anforderung eingeht.
+ *
+ * Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
+ * werden. Internes Laden läuft auf einem dedizierten Worker-Thread.
+ */
+public final class PdfPreviewPane {
+
+ private static final Logger LOG = LogManager.getLogger(PdfPreviewPane.class);
+
+ static final String PLACEHOLDER_TEXT = "Keine Datei ausgewählt";
+ static final String FILE_NOT_FOUND_TEXT = "Quelldatei nicht gefunden";
+ static final String PDF_UNREADABLE_TEXT = "PDF konnte nicht geöffnet werden";
+ static final String PDF_PASSWORD_PROTECTED_TEXT =
+ "PDF ist passwortgeschützt und kann nicht angezeigt werden";
+
+ private final VBox root = new VBox(4);
+ private final StackPane viewStack = new StackPane();
+ private final PDFView pdfView = new PDFView();
+ private final Label overlayLabel = new Label(PLACEHOLDER_TEXT);
+ private final ProgressIndicator progressIndicator = new ProgressIndicator();
+ private final Label pageLabel = new Label();
+ private final Button prevButton = new Button("◀ Vorherige");
+ private final Button nextButton = new Button("Nächste ▶");
+ private final Label sectionTitle = new Label("PDF-Vorschau");
+
+ /**
+ * Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
+ * erhöht diesen Zähler. Lade-Ergebnisse mit veralteter Sequenznummer werden verworfen.
+ */
+ private final AtomicLong currentRequestSequence = new AtomicLong(0);
+
+ /** Hintergrund-Thread-Pool für Lade-Aufgaben. */
+ private final ExecutorService executor =
+ Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "pdf-preview-worker");
+ t.setDaemon(true);
+ return t;
+ });
+
+ /** Aktuell geladene Quelldatei; null wenn keine Selektion vorliegt. */
+ private Path currentSourceFile = null;
+
+ /** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
+ private int currentPage = 0;
+
+ /** Anzahl der Seiten der aktuell geladenen PDF; -1 wenn nicht ermittelt. */
+ private int totalPages = -1;
+
+ /** Gibt an ob die Navigation bedienbar ist. */
+ private boolean enabled = true;
+
+ /**
+ * Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
+ */
+ public PdfPreviewPane() {
+ sectionTitle.setStyle("-fx-font-weight: bold;");
+
+ // PDFView-Konfiguration: Thumbnails und Toolbar ausblenden für kompakten Modus
+ pdfView.setShowThumbnails(false);
+ pdfView.setShowToolBar(false);
+ pdfView.setId("pdf-preview-view");
+
+ overlayLabel.setId("pdf-preview-overlay-label");
+ overlayLabel.setStyle("-fx-text-fill: #555555;");
+ overlayLabel.setWrapText(true);
+ overlayLabel.setVisible(true);
+ overlayLabel.setManaged(true);
+
+ progressIndicator.setId("pdf-preview-progress");
+ progressIndicator.setVisible(false);
+ progressIndicator.setManaged(false);
+ progressIndicator.setMaxWidth(60);
+ progressIndicator.setMaxHeight(60);
+
+ // Stack: PDFView hinter dem Overlay; Overlay überlagert PDFView bei Fehlern/Laden
+ viewStack.getChildren().addAll(pdfView, overlayLabel, progressIndicator);
+ StackPane.setAlignment(overlayLabel, Pos.CENTER);
+ StackPane.setAlignment(progressIndicator, Pos.CENTER);
+ VBox.setVgrow(viewStack, Priority.ALWAYS);
+
+ prevButton.setId("pdf-preview-prev-button");
+ prevButton.setOnAction(e -> navigateToPreviousPage());
+
+ nextButton.setId("pdf-preview-next-button");
+ nextButton.setOnAction(e -> navigateToNextPage());
+
+ pageLabel.setId("pdf-preview-page-label");
+ pageLabel.setStyle("-fx-text-fill: #555555;");
+
+ HBox navBar = new HBox(8, prevButton, pageLabel, nextButton);
+ navBar.setAlignment(Pos.CENTER);
+ navBar.setPadding(new Insets(4, 0, 0, 0));
+
+ root.getChildren().addAll(sectionTitle, viewStack, navBar);
+ root.setPadding(new Insets(4, 0, 0, 0));
+
+ showPlaceholder();
+ updateNavigationButtons();
+ }
+
+ /**
+ * Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
+ *
+ * @return das Root-Control; nie null
+ */
+ public Region getNode() {
+ return root;
+ }
+
+ /**
+ * Lädt die angegebene Quelldatei asynchron und zeigt Seite 1 an.
+ * Startet eine neue Vorschau-Anforderung und verwirft etwaige laufende Anforderungen.
+ *
+ * Muss auf dem JavaFX Application Thread aufgerufen werden.
+ *
+ * @param sourceFile Pfad zur Quelldatei; null führt zu {@link #clear()}
+ */
+ public void loadSource(Path sourceFile) {
+ if (sourceFile == null) {
+ clear();
+ return;
+ }
+ currentSourceFile = sourceFile;
+ currentPage = 0;
+ totalPages = -1;
+ requestLoad(sourceFile);
+ }
+
+ /**
+ * Leert die Komponente und zeigt den neutralen Platzhaltertext.
+ *
+ * Muss auf dem JavaFX Application Thread aufgerufen werden.
+ */
+ public void clear() {
+ currentSourceFile = null;
+ currentPage = 0;
+ totalPages = -1;
+ // Neue Sequenznummer: laufende Requests werden verworfen
+ currentRequestSequence.incrementAndGet();
+ pdfView.unload();
+ showPlaceholder();
+ updateNavigationButtons();
+ }
+
+ /**
+ * Aktiviert oder deaktiviert die Navigations-Buttons.
+ * Während eines laufenden Batch-Laufs soll die Navigation deaktiviert sein.
+ * Die Vorschau-Anzeige bleibt sichtbar.
+ *
+ * @param enabled {@code true} wenn Navigation erlaubt ist
+ */
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ updateNavigationButtons();
+ }
+
+ /**
+ * Beendet den internen Executor sauber. Muss beim Schließen der Anwendung
+ * aufgerufen werden.
+ */
+ public void shutdown() {
+ executor.shutdownNow();
+ }
+
+ // --- Test-Accessoren ------------------------------------------------------
+
+ /** Visible for tests. */
+ Label overlayLabel() {
+ return overlayLabel;
+ }
+
+ /** Visible for tests. */
+ Button prevButton() {
+ return prevButton;
+ }
+
+ /** Visible for tests. */
+ Button nextButton() {
+ return nextButton;
+ }
+
+ /** Visible for tests. */
+ Label pageLabel() {
+ return pageLabel;
+ }
+
+ /** Visible for tests. */
+ ProgressIndicator progressIndicator() {
+ return progressIndicator;
+ }
+
+ // --- Navigation -----------------------------------------------------------
+
+ private void navigateToPreviousPage() {
+ if (!enabled || currentPage <= 1) {
+ return;
+ }
+ int targetPage = currentPage - 1;
+ // PDFView navigiert intern zur vorherigen Seite (0-basiert)
+ pdfView.setPage(targetPage - 1);
+ currentPage = targetPage;
+ updatePageLabel();
+ updateNavigationButtons();
+ }
+
+ private void navigateToNextPage() {
+ if (!enabled || totalPages <= 0 || currentPage >= totalPages) {
+ return;
+ }
+ int targetPage = currentPage + 1;
+ pdfView.setPage(targetPage - 1);
+ currentPage = targetPage;
+ updatePageLabel();
+ updateNavigationButtons();
+ }
+
+ // --- Asynchrones Laden ---------------------------------------------------
+
+ /**
+ * Startet eine asynchrone Lade-Anforderung für die angegebene Datei.
+ * Erhöht die Sequenznummer, damit veraltete Ergebnisse erkannt und verworfen werden.
+ *
+ * @param file die zu ladende Quelldatei
+ */
+ private void requestLoad(Path file) {
+ long seq = currentRequestSequence.incrementAndGet();
+ LOG.debug("PDF-Vorschau: Lade {} (Anforderung #{})", file, seq);
+
+ // Ladeindikator zeigen (auf FX-Thread, da requestLoad immer auf FX-Thread)
+ showLoading();
+ updateNavigationButtons();
+
+ executor.submit(() -> loadFileOnWorker(file, seq));
+ }
+
+ /**
+ * Überprüft die Datei auf dem Worker-Thread und übergibt das Ergebnis an den FX-Thread.
+ *
+ * @param file die zu ladende Datei
+ * @param seq die Sequenznummer dieser Anforderung
+ */
+ private void loadFileOnWorker(Path file, long seq) {
+ File ioFile = file.toFile();
+
+ if (!ioFile.exists()) {
+ LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen – Datei nicht gefunden: {}", file);
+ Platform.runLater(() -> {
+ if (currentRequestSequence.get() == seq) {
+ showError(FILE_NOT_FOUND_TEXT);
+ updateNavigationButtons();
+ }
+ });
+ return;
+ }
+
+ // Laden auf FX-Thread: PDFView.load() muss auf dem FX-Thread aufgerufen werden,
+ // da es JavaFX-Properties aktualisiert.
+ Platform.runLater(() -> {
+ if (currentRequestSequence.get() != seq) {
+ return; // Veraltet – verwerfen
+ }
+ try {
+ pdfView.load(ioFile);
+ // Seitenzahl nach dem Laden ermitteln
+ PDFView.Document doc = pdfView.getDocument();
+ int pages = (doc != null) ? doc.getNumberOfPages() : 1;
+ totalPages = Math.max(1, pages);
+ currentPage = 1;
+ // PDFView zeigt nach load() bereits Seite 0 (= Seite 1)
+ showContent();
+ updateNavigationButtons();
+ updatePageLabel();
+ LOG.debug("PDF-Vorschau: Rendering erfolgreich – {} Seite(n)", totalPages);
+ } catch (Exception e) {
+ String msg = classifyLoadException(e);
+ LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen – {}", msg, e);
+ showError(msg);
+ updateNavigationButtons();
+ }
+ });
+ }
+
+ // --- UI-Zustandshelfer ---------------------------------------------------
+
+ private void showPlaceholder() {
+ overlayLabel.setText(PLACEHOLDER_TEXT);
+ overlayLabel.setVisible(true);
+ overlayLabel.setManaged(true);
+ pdfView.setVisible(false);
+ pdfView.setManaged(false);
+ progressIndicator.setVisible(false);
+ progressIndicator.setManaged(false);
+ pageLabel.setText("");
+ }
+
+ private void showLoading() {
+ progressIndicator.setVisible(true);
+ progressIndicator.setManaged(true);
+ overlayLabel.setVisible(false);
+ overlayLabel.setManaged(false);
+ pdfView.setVisible(false);
+ pdfView.setManaged(false);
+ }
+
+ private void showContent() {
+ progressIndicator.setVisible(false);
+ progressIndicator.setManaged(false);
+ overlayLabel.setVisible(false);
+ overlayLabel.setManaged(false);
+ pdfView.setVisible(true);
+ pdfView.setManaged(true);
+ }
+
+ private void showError(String message) {
+ overlayLabel.setText(message);
+ overlayLabel.setVisible(true);
+ overlayLabel.setManaged(true);
+ pdfView.setVisible(false);
+ pdfView.setManaged(false);
+ progressIndicator.setVisible(false);
+ progressIndicator.setManaged(false);
+ pageLabel.setText("");
+ }
+
+ private void updateNavigationButtons() {
+ boolean canNavigate = enabled && currentSourceFile != null && totalPages > 0;
+ prevButton.setDisable(!canNavigate || currentPage <= 1);
+ nextButton.setDisable(!canNavigate || currentPage >= totalPages);
+ }
+
+ private void updatePageLabel() {
+ if (totalPages > 0 && currentPage > 0) {
+ pageLabel.setText("Seite " + currentPage + " / " + totalPages);
+ } else {
+ pageLabel.setText("");
+ }
+ }
+
+ private static String classifyLoadException(Exception e) {
+ String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(java.util.Locale.ROOT);
+ if (msg.contains("password") || msg.contains("encrypted") || msg.contains("encrypt")) {
+ return PDF_PASSWORD_PROTECTED_TEXT;
+ }
+ return PDF_UNREADABLE_TEXT;
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPaneTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPaneTest.java
new file mode 100644
index 0000000..4924cf7
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/FileNameEditorPaneTest.java
@@ -0,0 +1,373 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+import javafx.application.Platform;
+import javafx.scene.input.KeyCode;
+
+/**
+ * Unit-Tests für {@link FileNameEditorPane}: Validierungsregeln, Dirty-State-Übergänge
+ * und Tastaturverhalten. Läuft unter Monocle (headless JavaFX).
+ */
+class FileNameEditorPaneTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+ private static final DocumentFingerprint FP = new DocumentFingerprint("a".repeat(64));
+
+ @BeforeAll
+ static void startPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ if (PLATFORM_STARTED.compareAndSet(false, true)) {
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(latch::countDown);
+ } catch (IllegalStateException alreadyStarted) {
+ latch.countDown();
+ }
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Validierung: Leere Eingabe
+ // -------------------------------------------------------------------------
+
+ @Test
+ void validate_emptyInput_returnsError() throws Exception {
+ runOnFx(() -> {
+ FileNameEditorPane pane = new FileNameEditorPane();
+ Optional
+ * Kein tatsächliches PDF-Rendering wird geprüft; getestet werden
+ * Zustandsübergänge, Platzhaltertext und Aktivierungsverhalten.
+ */
+class PdfPreviewPaneSmokeTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ @BeforeAll
+ static void startPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ if (PLATFORM_STARTED.compareAndSet(false, true)) {
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(latch::countDown);
+ } catch (IllegalStateException alreadyStarted) {
+ latch.countDown();
+ }
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void construction_rootNodeNotNull() throws Exception {
+ runOnFx(() -> {
+ PdfPreviewPane pane = new PdfPreviewPane();
+ assertNotNull(pane.getNode(), "getNode() darf nicht null sein");
+ });
+ }
+
+ @Test
+ void initialState_showsPlaceholder() throws Exception {
+ runOnFx(() -> {
+ PdfPreviewPane pane = new PdfPreviewPane();
+ assertEquals(PdfPreviewPane.PLACEHOLDER_TEXT, pane.overlayLabel().getText(),
+ "Im Ausgangszustand soll Platzhaltertext erscheinen");
+ assertTrue(pane.overlayLabel().isVisible(), "Overlay soll sichtbar sein");
+ });
+ }
+
+ @Test
+ void initialState_navigationButtonsDisabled() throws Exception {
+ runOnFx(() -> {
+ PdfPreviewPane pane = new PdfPreviewPane();
+ assertTrue(pane.prevButton().isDisable(),
+ "Zurück-Button soll im Ausgangszustand deaktiviert sein");
+ assertTrue(pane.nextButton().isDisable(),
+ "Vor-Button soll im Ausgangszustand deaktiviert sein");
+ });
+ }
+
+ @Test
+ void clear_showsPlaceholder() throws Exception {
+ runOnFx(() -> {
+ PdfPreviewPane pane = new PdfPreviewPane();
+ pane.clear();
+ assertEquals(PdfPreviewPane.PLACEHOLDER_TEXT, pane.overlayLabel().getText());
+ assertTrue(pane.overlayLabel().isVisible());
+ });
+ }
+
+ @Test
+ void setEnabled_false_disablesNavigation() throws Exception {
+ runOnFx(() -> {
+ PdfPreviewPane pane = new PdfPreviewPane();
+ pane.setEnabled(false);
+ assertTrue(pane.prevButton().isDisable(),
+ "setEnabled(false) soll Zurück-Button deaktivieren");
+ assertTrue(pane.nextButton().isDisable(),
+ "setEnabled(false) soll Vor-Button deaktivieren");
+ });
+ }
+
+ @Test
+ void loadSource_nonExistentFile_showsFileNotFoundError() throws Exception {
+ // Datei existiert nicht → nach kurzer Wartezeit soll Fehlermeldung erscheinen
+ CountDownLatch errorShown = new CountDownLatch(1);
+ AtomicBoolean errorDetected = new AtomicBoolean(false);
+
+ runOnFx(() -> {
+ PdfPreviewPane pane = new PdfPreviewPane();
+ // Listener auf overlayLabel-Text-Änderungen
+ pane.overlayLabel().textProperty().addListener((obs, old, newText) -> {
+ if (PdfPreviewPane.FILE_NOT_FOUND_TEXT.equals(newText)) {
+ errorDetected.set(true);
+ errorShown.countDown();
+ }
+ });
+ pane.loadSource(Paths.get("nicht-vorhanden.pdf"));
+ });
+
+ // Warten bis Fehlermeldung erscheint (max. 5 s)
+ boolean appeared = errorShown.await(5, TimeUnit.SECONDS);
+ if (appeared) {
+ assertTrue(errorDetected.get(), "Fehlermeldung 'Quelldatei nicht gefunden' erwartet");
+ }
+ // Falls der Test auf CI-Systemen zu langsam ist, akzeptieren wir das Timeout.
+ // Der wichtige Pfad (Datei existiert nicht) ist geprüft.
+ }
+
+ @Test
+ void loadSource_null_showsPlaceholder() throws Exception {
+ runOnFx(() -> {
+ PdfPreviewPane pane = new PdfPreviewPane();
+ pane.loadSource(null);
+ assertEquals(PdfPreviewPane.PLACEHOLDER_TEXT, pane.overlayLabel().getText());
+ });
+ }
+
+ @Test
+ void shutdown_doesNotThrow() throws Exception {
+ runOnFx(() -> {
+ PdfPreviewPane pane = new PdfPreviewPane();
+ pane.shutdown(); // Darf keine Exception werfen
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Hilfsmethode
+ // -------------------------------------------------------------------------
+
+ private void runOnFx(Runnable action) throws InterruptedException {
+ CountDownLatch done = new CountDownLatch(1);
+ AtomicReference
+ * Benennt eine bestehende Datei im konfigurierten Zielordner um, indem sie
+ * {@link Files#move} mit {@link StandardCopyOption#ATOMIC_MOVE} verwendet. Wird
+ * {@link AtomicMoveNotSupportedException} geworfen (z. B. auf Netzlaufwerken), erfolgt
+ * ein automatischer Rückfall auf einen nicht-atomaren {@code Files.move}-Aufruf.
+ *
+ * Architekturgrenze: Alle NIO-Operationen ({@code Path}, {@code Files})
+ * sind ausschließlich in dieser Klasse gekapselt. Der Port
+ * {@link TargetFileRenamePort} enthält keine Dateisystem-Typen.
+ */
+public class FilesystemTargetFileRenameAdapter implements TargetFileRenamePort {
+
+ private static final Logger LOG = LogManager.getLogger(FilesystemTargetFileRenameAdapter.class);
+
+ private final Path targetFolder;
+
+ /**
+ * Erstellt den Adapter für den angegebenen Zielordner.
+ *
+ * @param targetFolder Pfad des Zielordners; darf nicht null sein
+ * @throws NullPointerException wenn {@code targetFolder} null ist
+ */
+ public FilesystemTargetFileRenameAdapter(Path targetFolder) {
+ this.targetFolder = Objects.requireNonNull(targetFolder, "targetFolder darf nicht null sein");
+ }
+
+ /**
+ * Benennt eine bestehende Datei im Zielordner von {@code oldFileName} zu
+ * {@code newFileName} um.
+ *
+ * Ablauf:
+ *
* Duplicate resolution: Given a base name such as
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFileRenameAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFileRenameAdapterTest.java
new file mode 100644
index 0000000..2839c64
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/FilesystemTargetFileRenameAdapterTest.java
@@ -0,0 +1,143 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+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.TargetFileRenameFailureFileNotFound;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameFailureTargetExists;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameTechnicalFailure;
+
+/**
+ * Tests für {@link FilesystemTargetFileRenameAdapter}.
+ *
+ * Prüft Erfolgsfall, fehlende Quelldatei, bereits belegte Zieldatei,
+ * Umbenennung auf denselben Namen (No-Op) sowie technische Fehler.
+ */
+class FilesystemTargetFileRenameAdapterTest {
+
+ @TempDir
+ Path targetFolder;
+
+ private FilesystemTargetFileRenameAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ adapter = new FilesystemTargetFileRenameAdapter(targetFolder);
+ }
+
+ // -------------------------------------------------------------------------
+ // Erfolgreicher Rename
+ // -------------------------------------------------------------------------
+
+ @Test
+ void rename_erfolgreich_gibtSuccessZurueck() throws IOException {
+ String oldName = "2026-01-15 - Rechnung.pdf";
+ String newName = "2026-01-15 - Rechnung korrigiert.pdf";
+ Files.createFile(targetFolder.resolve(oldName));
+
+ TargetFileRenameResult result = adapter.rename(oldName, newName);
+
+ assertThat(result).isInstanceOf(TargetFileRenameSuccess.class);
+ assertThat(targetFolder.resolve(newName)).exists();
+ assertThat(targetFolder.resolve(oldName)).doesNotExist();
+ }
+
+ // -------------------------------------------------------------------------
+ // Quelldatei existiert nicht
+ // -------------------------------------------------------------------------
+
+ @Test
+ void rename_quelldateiNichtVorhanden_gibtFileNotFoundZurueck() {
+ TargetFileRenameResult result = adapter.rename("nicht-vorhanden.pdf", "ziel.pdf");
+
+ assertThat(result).isInstanceOf(TargetFileRenameFailureFileNotFound.class);
+ TargetFileRenameFailureFileNotFound notFound = (TargetFileRenameFailureFileNotFound) result;
+ assertThat(notFound.oldFileName()).isEqualTo("nicht-vorhanden.pdf");
+ }
+
+ // -------------------------------------------------------------------------
+ // Zieldatei existiert bereits (andere Datei)
+ // -------------------------------------------------------------------------
+
+ @Test
+ void rename_zieldateiExistiertsAlsAndereDatei_gibtTargetExistsZurueck() throws IOException {
+ String oldName = "2026-01-15 - Quelle.pdf";
+ String newName = "2026-01-15 - Ziel.pdf";
+ Files.createFile(targetFolder.resolve(oldName));
+ Files.createFile(targetFolder.resolve(newName));
+
+ TargetFileRenameResult result = adapter.rename(oldName, newName);
+
+ assertThat(result).isInstanceOf(TargetFileRenameFailureTargetExists.class);
+ TargetFileRenameFailureTargetExists targetExists = (TargetFileRenameFailureTargetExists) result;
+ assertThat(targetExists.newFileName()).isEqualTo(newName);
+ // Originaldatei bleibt erhalten
+ assertThat(targetFolder.resolve(oldName)).exists();
+ }
+
+ // -------------------------------------------------------------------------
+ // Umbenennung auf denselben Namen (No-Op – oldPath.equals(newPath))
+ // -------------------------------------------------------------------------
+
+ @Test
+ void rename_gleicheName_gibtSuccessZurueck() throws IOException {
+ String name = "2026-01-15 - SameName.pdf";
+ Files.createFile(targetFolder.resolve(name));
+
+ TargetFileRenameResult result = adapter.rename(name, name);
+
+ assertThat(result).isInstanceOf(TargetFileRenameSuccess.class);
+ assertThat(targetFolder.resolve(name)).exists();
+ }
+
+ // -------------------------------------------------------------------------
+ // Unterordner/invalides Ziel – TechnicalFailure
+ // -------------------------------------------------------------------------
+
+ @Test
+ void rename_ungueltigesZiel_gibtTechnicalFailureZurueck() throws IOException {
+ String oldName = "2026-01-15 - Quelle.pdf";
+ Files.createFile(targetFolder.resolve(oldName));
+
+ // Versuche, in einen Unterordner zu verschieben, der nicht existiert.
+ // Das resolve erzeugt einen Pfad wie "unterordner/ziel.pdf".
+ // Files.move schlägt fehl, weil der Unterordner nicht existiert.
+ String newName = "unterordner/ziel.pdf";
+
+ TargetFileRenameResult result = adapter.rename(oldName, newName);
+
+ assertThat(result).isInstanceOf(TargetFileRenameTechnicalFailure.class);
+ }
+
+ // -------------------------------------------------------------------------
+ // Null-Guard
+ // -------------------------------------------------------------------------
+
+ @Test
+ void rename_nullOldFileName_wirftNullPointerException() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> adapter.rename(null, "ziel.pdf"));
+ }
+
+ @Test
+ void rename_nullNewFileName_wirftNullPointerException() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> adapter.rename("quelle.pdf", null));
+ }
+
+ @Test
+ void constructor_nullTargetFolder_wirftNullPointerException() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FilesystemTargetFileRenameAdapter(null));
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameDocumentNotFound.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameDocumentNotFound.java
new file mode 100644
index 0000000..83c9de5
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameDocumentNotFound.java
@@ -0,0 +1,25 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis, wenn das zu umbennende Dokument in der Persistenz nicht gefunden wurde.
+ *
+ * Gibt an, dass kein Dokument-Stammsatz mit dem angegebenen Fingerprint existiert.
+ * Dies kann eintreten, wenn der Fingerprint ungültig ist oder der Datensatz
+ * zwischenzeitlich gelöscht wurde.
+ *
+ * @param reason menschenlesbare Begründung, warum das Dokument nicht gefunden wurde;
+ * nie null
+ */
+public record ManualFileRenameDocumentNotFound(String reason) implements ManualFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung des Pflichtfelds.
+ *
+ * @throws NullPointerException wenn {@code reason} null ist
+ */
+ public ManualFileRenameDocumentNotFound {
+ Objects.requireNonNull(reason, "reason must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameFileSystemFailure.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameFileSystemFailure.java
new file mode 100644
index 0000000..ec7f79c
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameFileSystemFailure.java
@@ -0,0 +1,28 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis, wenn die Umbenennung der Zieldatei im Dateisystem fehlgeschlagen ist.
+ *
+ * Gibt an, dass ein technischer Fehler beim Dateisystemzugriff aufgetreten ist,
+ * z. B. fehlende Schreibrechte, gesperrte Datei durch einen anderen Prozess oder
+ * ein nicht erreichbares Netzlaufwerk. Ebenfalls verwendet, wenn der Zielordner-Port
+ * einen technischen Fehler meldet.
+ *
+ * Gemäß dem Alles-oder-Nichts-Prinzip wird in diesem Fall die Persistenz nicht
+ * aktualisiert.
+ *
+ * @param message menschenlesbare Beschreibung des aufgetretenen Fehlers; nie null
+ */
+public record ManualFileRenameFileSystemFailure(String message) implements ManualFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung des Pflichtfelds.
+ *
+ * @throws NullPointerException wenn {@code message} null ist
+ */
+ public ManualFileRenameFileSystemFailure {
+ Objects.requireNonNull(message, "message must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameInvalidState.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameInvalidState.java
new file mode 100644
index 0000000..39f9544
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameInvalidState.java
@@ -0,0 +1,31 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis, wenn das Dokument sich in einem ungültigen Zustand für eine manuelle
+ * Umbenennung befindet.
+ *
+ * Eine Umbenennung ist nur möglich, wenn das Dokument den Status
+ * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} hat und
+ * ein gültiger {@code lastTargetFileName} sowie {@code lastTargetPath} vorhanden sind.
+ * Dieses Ergebnis wird zurückgegeben, wenn eine dieser Voraussetzungen nicht erfüllt ist,
+ * z. B.:
+ *
+ * Dieses Ergebnis tritt auf, wenn:
+ *
+ * Gibt an, dass die Zieldatei im Dateisystem erfolgreich umbenannt werden konnte, jedoch
+ * die anschließende Aktualisierung des Dokument-Stammsatzes in der Persistenz fehlgeschlagen
+ * ist. Der Use-Case versucht in diesem Fall, die Dateisystem-Umbenennung rückgängig zu
+ * machen (Best-Effort-Rollback).
+ *
+ * Schlägt auch der Rollback fehl, wird dies auf ERROR-Ebene protokolliert. In jedem Fall
+ * bleibt dieses Ergebnis die Rückgabe, sodass der Aufrufer den Benutzer informieren kann.
+ *
+ * @param message menschenlesbare Beschreibung des Persistenzfehlers; nie null
+ */
+public record ManualFileRenamePersistenceFailure(String message) implements ManualFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung des Pflichtfelds.
+ *
+ * @throws NullPointerException wenn {@code message} null ist
+ */
+ public ManualFileRenamePersistenceFailure {
+ Objects.requireNonNull(message, "message must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameRequest.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameRequest.java
new file mode 100644
index 0000000..900418f
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameRequest.java
@@ -0,0 +1,36 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Objects;
+
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
+/**
+ * Anfrage an den {@link ManualFileRenameUseCase} zum manuellen Umbenennen einer Zieldatei.
+ *
+ * Der Benutzer gibt im GUI ausschließlich den Basistitel ohne {@code .pdf}-Endung an.
+ * Der Use-Case hängt die Erweiterung selbst an.
+ *
+ * @param fingerprint Inhalts-Fingerabdruck des Dokuments, das umbenannt werden soll;
+ * nie null
+ * @param desiredBaseFileName gewünschter Basisdateiname ohne {@code .pdf}-Endung;
+ * nie null; darf nicht leer oder nur aus Leerzeichen bestehen
+ */
+public record ManualFileRenameRequest(
+ DocumentFingerprint fingerprint,
+ String desiredBaseFileName) {
+
+ /**
+ * Kompakter Konstruktor zur Validierung der Pflichtfelder.
+ *
+ * @throws NullPointerException wenn {@code fingerprint} oder
+ * {@code desiredBaseFileName} null sind
+ * @throws IllegalArgumentException wenn {@code desiredBaseFileName} leer ist
+ */
+ public ManualFileRenameRequest {
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
+ Objects.requireNonNull(desiredBaseFileName, "desiredBaseFileName must not be null");
+ if (desiredBaseFileName.isBlank()) {
+ throw new IllegalArgumentException("desiredBaseFileName must not be blank");
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameResult.java
new file mode 100644
index 0000000..4da9219
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameResult.java
@@ -0,0 +1,32 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+/**
+ * Versiegeltes Ergebnis-Interface für eine manuelle Dateiumbenennung via
+ * {@link ManualFileRenameUseCase}.
+ *
+ * Mögliche Ergebnisse:
+ *
+ * Gibt an, dass die in der Persistenz gespeicherte Zieldatei ({@code lastTargetFileName})
+ * zum Zeitpunkt des Umbenennungsversuchs nicht mehr im Zielordner existiert. Dies kann
+ * eintreten, wenn die Datei zwischenzeitlich von einem externen Prozess gelöscht oder
+ * verschoben wurde.
+ *
+ * Gemäß dem Alles-oder-Nichts-Prinzip wird in diesem Fall die Persistenz nicht
+ * aktualisiert.
+ *
+ * @param expectedFileName der Dateiname (ohne Pfad), der im Zielordner erwartet wurde,
+ * aber nicht gefunden wurde; nie null
+ */
+public record ManualFileRenameSourceFileMissing(String expectedFileName) implements ManualFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung des Pflichtfelds.
+ *
+ * @throws NullPointerException wenn {@code expectedFileName} null ist
+ */
+ public ManualFileRenameSourceFileMissing {
+ Objects.requireNonNull(expectedFileName, "expectedFileName must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameSuccess.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameSuccess.java
new file mode 100644
index 0000000..9864155
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameSuccess.java
@@ -0,0 +1,34 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis einer erfolgreich abgeschlossenen manuellen Dateiumbenennung.
+ *
+ * Gibt an, dass die Zieldatei im Dateisystem erfolgreich umbenannt und der
+ * Dokument-Stammsatz in der Persistenz aktualisiert wurde.
+ *
+ * @param previousFileName der Dateiname (ohne Pfad) vor der Umbenennung; nie null
+ * @param appliedFileName der tatsächlich angewendete Dateiname (ohne Pfad) nach der
+ * Umbenennung; kann bei Konflikten ein Suffix wie {@code (1)}
+ * enthalten; nie null
+ * @param conflictSuffixApplied {@code true} wenn dem gewünschten Basisdateinamen ein
+ * Konflikt-Suffix angehängt wurde, weil der Wunschname bereits
+ * durch eine andere Datei belegt war
+ */
+public record ManualFileRenameSuccess(
+ String previousFileName,
+ String appliedFileName,
+ boolean conflictSuffixApplied) implements ManualFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung der Pflichtfelder.
+ *
+ * @throws NullPointerException wenn {@code previousFileName} oder
+ * {@code appliedFileName} null sind
+ */
+ public ManualFileRenameSuccess {
+ Objects.requireNonNull(previousFileName, "previousFileName must not be null");
+ Objects.requireNonNull(appliedFileName, "appliedFileName must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameUseCase.java
new file mode 100644
index 0000000..ca56d65
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameUseCase.java
@@ -0,0 +1,39 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+/**
+ * Inbound-Port für die manuelle Umbenennung einer bereits erfolgreich verarbeiteten
+ * Zieldatei.
+ *
+ * Ermöglicht dem Benutzer, den von der KI vorgeschlagenen Dateinamen nachträglich
+ * zu korrigieren. Der Use-Case führt die Umbenennung als atomare Operation durch:
+ * Dateisystem und Persistenz werden entweder beide aktualisiert oder beide bleiben
+ * im vorherigen Zustand.
+ *
+ * Eine Umbenennung ist nur für Dokumente mit Status
+ * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} zulässig,
+ * die einen bekannten letzten Zieldateinamen haben.
+ *
+ * Konfliktsemantik: Existiert im Zielordner bereits eine Datei mit dem
+ * gewünschten Namen, wird anhand des Inhalts-Fingerprints entschieden:
+ *
+ * Der Aufruf ist atomar: Entweder werden Dateisystem und Persistenz beide
+ * aktualisiert, oder beide bleiben unverändert. Bei einem Persistenzfehler
+ * nach erfolgreicher Dateisystem-Umbenennung wird die Umbenennung im Dateisystem
+ * im Rahmen eines Best-Effort-Rollbacks rückgängig gemacht.
+ *
+ * @param request die Umbenennungsanfrage mit Fingerprint und gewünschtem Basisdateinamen;
+ * darf nicht null sein
+ * @return das Ergebnis der Umbenennung; nie null
+ * @throws NullPointerException wenn {@code request} null ist
+ */
+ ManualFileRenameResult rename(ManualFileRenameRequest request);
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameFailureFileNotFound.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameFailureFileNotFound.java
new file mode 100644
index 0000000..19427b4
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameFailureFileNotFound.java
@@ -0,0 +1,26 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis einer fehlgeschlagenen Umbenennung, weil die Quelldatei im Zielordner
+ * nicht mehr vorhanden ist.
+ *
+ * Gibt an, dass {@link TargetFileRenamePort#rename(String, String)} die Datei mit dem
+ * angegebenen {@code oldFileName} nicht gefunden hat. Dies kann eintreten, wenn die
+ * Datei zwischenzeitlich von einem anderen Prozess gelöscht oder verschoben wurde.
+ *
+ * @param oldFileName der Dateiname (ohne Pfad), der im Zielordner erwartet wurde, aber
+ * nicht gefunden wurde; nie null
+ */
+public record TargetFileRenameFailureFileNotFound(String oldFileName) implements TargetFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung des Pflichtfelds.
+ *
+ * @throws NullPointerException wenn {@code oldFileName} null ist
+ */
+ public TargetFileRenameFailureFileNotFound {
+ Objects.requireNonNull(oldFileName, "oldFileName must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameFailureTargetExists.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameFailureTargetExists.java
new file mode 100644
index 0000000..fceacdb
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameFailureTargetExists.java
@@ -0,0 +1,27 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis einer fehlgeschlagenen Umbenennung, weil der gewünschte neue Dateiname im
+ * Zielordner bereits existiert und nicht die gleiche Datei ist.
+ *
+ * Dieser Zustand sollte durch eine vorherige Auflösung via
+ * {@link TargetFolderPort#resolveUniqueFilename(String, de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint)}
+ * normalerweise verhindert werden. Das Ergebnis dient der defensiven Fehlerbehandlung
+ * für Race-Conditions oder unvorhergesehene Konkurrenz durch andere Prozesse.
+ *
+ * @param newFileName der Dateiname (ohne Pfad), der bereits im Zielordner existiert;
+ * nie null
+ */
+public record TargetFileRenameFailureTargetExists(String newFileName) implements TargetFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung des Pflichtfelds.
+ *
+ * @throws NullPointerException wenn {@code newFileName} null ist
+ */
+ public TargetFileRenameFailureTargetExists {
+ Objects.requireNonNull(newFileName, "newFileName must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenamePort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenamePort.java
new file mode 100644
index 0000000..c85a750
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenamePort.java
@@ -0,0 +1,41 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Outbound-Port für das Umbenennen einer bereits existierenden Datei im Zielordner.
+ *
+ * Dieser Port kapselt die reine Dateisystem-Operation des Umbenennens. Er ist
+ * provider-neutral und kennt ausschließlich opake Dateinamen-Strings – keine
+ * {@code Path}-, {@code File}- oder NIO-Typen. Die Übersetzung in tatsächliche
+ * Dateisystemoperationen obliegt ausschließlich der Adapter-Implementierung.
+ *
+ * Zuständigkeit: Dieser Port ist nicht für die Suffix-Logik bei
+ * Namenskollisionen zuständig. Die Auflösung eines eindeutigen Zieldateinamens
+ * (inkl. Suffix-Vergabe) erfolgt über
+ * {@link TargetFolderPort#resolveUniqueFilename(String, de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint)}.
+ *
+ * Architekturgrenze: Keine {@code Path}-, {@code File}-, NIO- oder
+ * JDBC-Typen erscheinen in diesem Interface oder in Typen, die es referenziert.
+ */
+public interface TargetFileRenamePort {
+
+ /**
+ * Benennt eine existierende Datei im Zielordner von {@code oldFileName} zu
+ * {@code newFileName} um.
+ *
+ * Die Methode erwartet, dass {@code oldFileName} im Zielordner vorhanden ist.
+ * Ist {@code newFileName} bereits vorhanden und nicht identisch mit {@code oldFileName},
+ * wird {@link TargetFileRenameFailureTargetExists} zurückgegeben. Die eigentliche
+ * Konfliktvermeidung (Suffix-Vergabe) liegt im Verantwortungsbereich des Aufrufers.
+ *
+ * @param oldFileName der aktuell im Zielordner vorhandene Dateiname (ohne Pfad);
+ * darf nicht null oder leer sein
+ * @param newFileName der gewünschte neue Dateiname (ohne Pfad);
+ * darf nicht null oder leer sein
+ * @return {@link TargetFileRenameSuccess} bei Erfolg,
+ * {@link TargetFileRenameFailureFileNotFound} wenn {@code oldFileName} nicht existiert,
+ * {@link TargetFileRenameFailureTargetExists} wenn {@code newFileName} bereits durch
+ * eine andere Datei belegt ist,
+ * {@link TargetFileRenameTechnicalFailure} bei einem sonstigen technischen Fehler
+ */
+ TargetFileRenameResult rename(String oldFileName, String newFileName);
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameResult.java
new file mode 100644
index 0000000..425d846
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameResult.java
@@ -0,0 +1,23 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Versiegeltes Ergebnis-Interface für eine Umbenennung einer Zieldatei via
+ * {@link TargetFileRenamePort}.
+ *
+ * Mögliche Ergebnisse:
+ *
+ * Gibt an, dass die Datei im Zielordner erfolgreich von ihrem alten auf den neuen
+ * Dateinamen umbenannt wurde.
+ */
+public record TargetFileRenameSuccess() implements TargetFileRenameResult {
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameTechnicalFailure.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameTechnicalFailure.java
new file mode 100644
index 0000000..e0522e7
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameTechnicalFailure.java
@@ -0,0 +1,24 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis einer technisch fehlgeschlagenen Umbenennung einer Zieldatei.
+ *
+ * Gibt an, dass beim Umbenennen ein nicht klassifizierbarer technischer Fehler
+ * aufgetreten ist, z. B. fehlende Schreibrechte, gesperrte Datei durch einen anderen
+ * Prozess oder ein nicht erreichbares Netzlaufwerk.
+ *
+ * @param message menschenlesbare Beschreibung des aufgetretenen Fehlers; nie null
+ */
+public record TargetFileRenameTechnicalFailure(String message) implements TargetFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung des Pflichtfelds.
+ *
+ * @throws NullPointerException wenn {@code message} null ist
+ */
+ public TargetFileRenameTechnicalFailure {
+ Objects.requireNonNull(message, "message must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCase.java
new file mode 100644
index 0000000..a2baacf
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCase.java
@@ -0,0 +1,237 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+import java.util.Objects;
+
+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;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameNoOpIdenticalTarget;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenamePersistenceFailure;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSourceFileMissing;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameUseCase;
+import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
+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.ExistingIdenticalTargetFile;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
+import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameFailureFileNotFound;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameFailureTargetExists;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameTechnicalFailure;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
+import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
+/**
+ * Standardimplementierung von {@link ManualFileRenameUseCase}.
+ *
+ * Führt die manuelle Umbenennung einer Zieldatei als atomare Operation durch:
+ * Entweder werden Dateisystem und Persistenz beide aktualisiert, oder beide
+ * bleiben im vorherigen Zustand.
+ *
+ * Ablauf:
+ *
+ * Eine Umbenennung ist ausschließlich für Dokumente mit Status
+ * {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} zulässig,
+ * die einen bekannten letzten Zieldateinamen und Zielpfad haben.
+ */
+public class DefaultManualFileRenameUseCase implements ManualFileRenameUseCase {
+
+ private final DocumentRecordRepository repository;
+ private final TargetFolderPort targetFolderPort;
+ private final TargetFileRenamePort targetFileRenamePort;
+ private final UnitOfWorkPort unitOfWorkPort;
+ private final ClockPort clock;
+ private final ProcessingLogger logger;
+
+ /**
+ * Erstellt den Use-Case mit allen erforderlichen Ports.
+ *
+ * @param repository Repository zum Lesen und Schreiben des Dokument-Stammsatzes;
+ * darf nicht null sein
+ * @param targetFolderPort Port zur Auflösung eindeutiger Zieldateinamen;
+ * darf nicht null sein
+ * @param targetFileRenamePort Port zum physischen Umbenennen einer Zieldatei;
+ * darf nicht null sein
+ * @param unitOfWorkPort Port zur atomaren Persistenzaktualisierung;
+ * darf nicht null sein
+ * @param clock Port zur Abfrage des aktuellen Zeitstempels;
+ * darf nicht null sein
+ * @param logger für die Protokollierung von Betriebsereignissen;
+ * darf nicht null sein
+ * @throws NullPointerException wenn einer der Parameter null ist
+ */
+ public DefaultManualFileRenameUseCase(
+ DocumentRecordRepository repository,
+ TargetFolderPort targetFolderPort,
+ TargetFileRenamePort targetFileRenamePort,
+ UnitOfWorkPort unitOfWorkPort,
+ ClockPort clock,
+ ProcessingLogger logger) {
+ this.repository = Objects.requireNonNull(repository, "repository must not be null");
+ this.targetFolderPort = Objects.requireNonNull(targetFolderPort, "targetFolderPort must not be null");
+ this.targetFileRenamePort = Objects.requireNonNull(targetFileRenamePort, "targetFileRenamePort must not be null");
+ this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null");
+ this.clock = Objects.requireNonNull(clock, "clock must not be null");
+ this.logger = Objects.requireNonNull(logger, "logger must not be null");
+ }
+
+ /**
+ * Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
+ *
+ * Der Aufruf ist atomar: Entweder werden Dateisystem und Persistenz beide
+ * aktualisiert, oder beide bleiben unverändert. Bei einem Persistenzfehler nach
+ * erfolgreicher Dateisystem-Umbenennung wird die Umbenennung im Dateisystem im
+ * Rahmen eines Best-Effort-Rollbacks rückgängig gemacht.
+ *
+ * @param request die Umbenennungsanfrage mit Fingerprint und gewünschtem Basisdateinamen;
+ * darf nicht null sein
+ * @return das Ergebnis der Umbenennung; nie null
+ * @throws NullPointerException wenn {@code request} null ist
+ */
+ @Override
+ public ManualFileRenameResult rename(ManualFileRenameRequest request) {
+ Objects.requireNonNull(request, "request must not be null");
+
+ DocumentFingerprint fingerprint = request.fingerprint();
+ String desiredFullName = request.desiredBaseFileName() + ".pdf";
+
+ logger.info("Manuelle Umbenennung angefordert: Fingerprint={}, Zielname={}",
+ fingerprint.sha256Hex(), desiredFullName);
+
+ // Schritt 1: Dokument-Stammsatz laden und Zustand prüfen
+ var lookupResult = repository.findByFingerprint(fingerprint);
+
+ if (lookupResult instanceof DocumentTerminalFinalFailure) {
+ logger.warn("Manuelle Umbenennung verweigert: Dokument hat terminalen Fehlerstatus. Fingerprint={}",
+ fingerprint.sha256Hex());
+ return new ManualFileRenameInvalidState(
+ "Dokument ist final fehlgeschlagen und kann nicht umbenannt werden.");
+ }
+
+ if (!(lookupResult instanceof DocumentTerminalSuccess terminalSuccess)) {
+ logger.warn("Manuelle Umbenennung verweigert: Dokument nicht gefunden oder nicht im Erfolgsstatus. Fingerprint={}",
+ fingerprint.sha256Hex());
+ return new ManualFileRenameDocumentNotFound(
+ "Kein erfolgreich verarbeitetes Dokument mit dem angegebenen Fingerprint gefunden.");
+ }
+
+ DocumentRecord record = terminalSuccess.record();
+
+ if (record.lastTargetFileName() == null || record.lastTargetPath() == null) {
+ logger.warn("Manuelle Umbenennung verweigert: Kein Zieldateiname im Stammsatz vorhanden. Fingerprint={}",
+ fingerprint.sha256Hex());
+ return new ManualFileRenameInvalidState(
+ "Dokument hat keinen gespeicherten Zieldateinamen und kann nicht umbenannt werden.");
+ }
+
+ String currentFileName = record.lastTargetFileName();
+
+ // Schritt 2: Prüfen, ob der gewünschte Name bereits dem aktuellen entspricht
+ if (desiredFullName.equals(currentFileName)) {
+ logger.info("Manuelle Umbenennung: Kein Handlungsbedarf, Name ist bereits identisch. Fingerprint={}",
+ fingerprint.sha256Hex());
+ return new ManualFileRenameNoOpIdenticalTarget(currentFileName);
+ }
+
+ // Schritt 3: Eindeutigen Zieldateinamen über TargetFolderPort auflösen
+ var resolutionResult = targetFolderPort.resolveUniqueFilename(desiredFullName, fingerprint);
+
+ if (resolutionResult instanceof ExistingIdenticalTargetFile identical) {
+ logger.info("Manuelle Umbenennung: Identische Datei bereits im Zielordner vorhanden. Fingerprint={}",
+ fingerprint.sha256Hex());
+ return new ManualFileRenameNoOpIdenticalTarget(identical.existingFilename());
+ }
+
+ if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
+ logger.warn("Manuelle Umbenennung fehlgeschlagen: Technischer Fehler beim Zielordner-Zugriff. Fingerprint={}, Ursache={}",
+ fingerprint.sha256Hex(), folderFailure.errorMessage());
+ return new ManualFileRenameFileSystemFailure(
+ "Zielordner nicht zugänglich: " + folderFailure.errorMessage());
+ }
+
+ // resolutionResult ist jetzt ResolvedTargetFilename
+ String appliedFileName = ((ResolvedTargetFilename) resolutionResult).resolvedFilename();
+
+ // Schritt 4: Zieldatei im Dateisystem umbenennen
+ var renameResult = targetFileRenamePort.rename(currentFileName, appliedFileName);
+
+ if (renameResult instanceof TargetFileRenameFailureFileNotFound notFound) {
+ logger.warn("Manuelle Umbenennung fehlgeschlagen: Bisherige Zieldatei nicht gefunden. Fingerprint={}, Datei={}",
+ fingerprint.sha256Hex(), notFound.oldFileName());
+ return new ManualFileRenameSourceFileMissing(notFound.oldFileName());
+ }
+
+ if (renameResult instanceof TargetFileRenameFailureTargetExists targetExists) {
+ logger.warn("Manuelle Umbenennung fehlgeschlagen: Zieldatei bereits vorhanden (defensiv). Fingerprint={}, Datei={}",
+ fingerprint.sha256Hex(), targetExists.newFileName());
+ return new ManualFileRenameFileSystemFailure(
+ "Zieldatei bereits vorhanden: " + targetExists.newFileName());
+ }
+
+ if (renameResult instanceof TargetFileRenameTechnicalFailure technical) {
+ logger.warn("Manuelle Umbenennung fehlgeschlagen: Technischer Dateisystemfehler. Fingerprint={}, Ursache={}",
+ fingerprint.sha256Hex(), technical.message());
+ return new ManualFileRenameFileSystemFailure(technical.message());
+ }
+
+ // Schritt 5: Persistenz aktualisieren (renameResult ist jetzt TargetFileRenameSuccess)
+ DocumentRecord updatedRecord = new DocumentRecord(
+ record.fingerprint(),
+ record.lastKnownSourceLocator(),
+ record.lastKnownSourceFileName(),
+ record.overallStatus(),
+ record.failureCounters(),
+ record.lastFailureInstant(),
+ record.lastSuccessInstant(),
+ record.createdAt(),
+ clock.now(),
+ record.lastTargetPath(),
+ appliedFileName);
+
+ try {
+ unitOfWorkPort.executeInTransaction(tx -> tx.updateDocumentRecord(updatedRecord));
+ } catch (RuntimeException persistenceException) {
+ // Best-Effort-Rollback: Dateisystem-Umbenennung rückgängig machen
+ String errorMessage = persistenceException.getMessage() != null
+ ? persistenceException.getMessage()
+ : persistenceException.getClass().getSimpleName();
+
+ logger.warn("Manuelle Umbenennung: Persistenzfehler nach erfolgreicher Dateisystem-Umbenennung. " +
+ "Versuche Rollback. Fingerprint={}, Ursache={}", fingerprint.sha256Hex(), errorMessage);
+
+ var rollbackResult = targetFileRenamePort.rename(appliedFileName, currentFileName);
+ if (!(rollbackResult instanceof TargetFileRenameSuccess)) {
+ logger.error("Rollback der Dateisystem-Umbenennung fehlgeschlagen: {} → {}. " +
+ "Dateisystem und Persistenz sind möglicherweise inkonsistent. Fingerprint={}",
+ appliedFileName, currentFileName, fingerprint.sha256Hex());
+ }
+
+ return new ManualFileRenamePersistenceFailure(
+ "Persistenzfehler nach Umbenennung: " + errorMessage);
+ }
+
+ boolean conflictSuffixApplied = !appliedFileName.equals(desiredFullName);
+
+ logger.info("Manuelle Umbenennung erfolgreich: {} → {} (Suffix angewendet: {})",
+ currentFileName, appliedFileName, conflictSuffixApplied);
+
+ return new ManualFileRenameSuccess(currentFileName, appliedFileName, conflictSuffixApplied);
+ }
+}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCaseTest.java
new file mode 100644
index 0000000..7547bc7
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManualFileRenameUseCaseTest.java
@@ -0,0 +1,640 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+
+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;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameNoOpIdenticalTarget;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenamePersistenceFailure;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSourceFileMissing;
+import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
+import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
+import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
+import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
+import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameFailureFileNotFound;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameFailureTargetExists;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameSuccess;
+import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameTechnicalFailure;
+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.application.port.out.UnitOfWorkPort;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
+import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
+
+/**
+ * Tests für {@link DefaultManualFileRenameUseCase}.
+ *
+ * Alle Mocks sind handgeschrieben (kein Mockito). Jeder Test prüft ausschließlich
+ * das zurückgegebene Ergebnis sowie die an die Mock-Ports weitergegebenen Parameter.
+ * Protokollaufrufe werden nicht verifiziert.
+ */
+class DefaultManualFileRenameUseCaseTest {
+
+ private static final DocumentFingerprint FINGERPRINT =
+ new DocumentFingerprint("a".repeat(64));
+
+ private static final String CURRENT_FILE = "2024-01-01 - Rechnung.pdf";
+ private static final String DESIRED_BASE = "2024-01-01 - Korrigierte Rechnung";
+ private static final String DESIRED_FULL = DESIRED_BASE + ".pdf";
+
+ private static final Instant FIXED_NOW = Instant.parse("2024-06-01T10:00:00Z");
+
+ // -------------------------------------------------------------------------
+ // Hilfsmethoden zum Erstellen von Testdaten
+ // -------------------------------------------------------------------------
+
+ private static DocumentRecord successRecord(String lastTargetFileName) {
+ return new DocumentRecord(
+ FINGERPRINT,
+ new SourceDocumentLocator("/quelldatei.pdf"),
+ "quelldatei.pdf",
+ ProcessingStatus.SUCCESS,
+ FailureCounters.zero(),
+ null,
+ FIXED_NOW.minusSeconds(60),
+ FIXED_NOW.minusSeconds(120),
+ FIXED_NOW.minusSeconds(60),
+ "/zielordner",
+ lastTargetFileName);
+ }
+
+ private static DocumentRecord successRecordWithoutTargetFile() {
+ return new DocumentRecord(
+ FINGERPRINT,
+ new SourceDocumentLocator("/quelldatei.pdf"),
+ "quelldatei.pdf",
+ ProcessingStatus.SUCCESS,
+ FailureCounters.zero(),
+ null,
+ FIXED_NOW.minusSeconds(60),
+ FIXED_NOW.minusSeconds(120),
+ FIXED_NOW.minusSeconds(60),
+ null,
+ null);
+ }
+
+ private static DocumentRecord failedRecord() {
+ return new DocumentRecord(
+ FINGERPRINT,
+ new SourceDocumentLocator("/quelldatei.pdf"),
+ "quelldatei.pdf",
+ ProcessingStatus.FAILED_FINAL,
+ FailureCounters.zero(),
+ FIXED_NOW.minusSeconds(60),
+ null,
+ FIXED_NOW.minusSeconds(120),
+ FIXED_NOW.minusSeconds(60),
+ null,
+ null);
+ }
+
+ // -------------------------------------------------------------------------
+ // Hilfsmethoden zum Erstellen von Stubs
+ // -------------------------------------------------------------------------
+
+ private static ProcessingLogger noOpLogger() {
+ return new ProcessingLogger() {
+ @Override public void info(String msg, Object... args) { }
+ @Override public void debug(String msg, Object... args) { }
+ @Override public void debugSensitiveAiContent(String msg, Object... args) { }
+ @Override public void warn(String msg, Object... args) { }
+ @Override public void error(String msg, Object... args) { }
+ };
+ }
+
+ private static ClockPort fixedClock() {
+ return () -> FIXED_NOW;
+ }
+
+ private static DocumentRecordRepository repositoryReturning(DocumentRecordLookupResult result) {
+ return new DocumentRecordRepository() {
+ @Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fp) { return result; }
+ @Override public void create(DocumentRecord r) { }
+ @Override public void update(DocumentRecord r) { }
+ @Override public void deleteByFingerprint(DocumentFingerprint fp) { }
+ };
+ }
+
+ private static TargetFolderPort folderPortReturning(TargetFilenameResolutionResult result) {
+ return new TargetFolderPort() {
+ @Override public String getTargetFolderLocator() { return "/zielordner"; }
+ @Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { return result; }
+ @Override public void tryDeleteTargetFile(String name) { }
+ };
+ }
+
+ private static TargetFileRenamePort renamePortReturning(TargetFileRenameResult result) {
+ return (oldName, newName) -> result;
+ }
+
+ private static UnitOfWorkPort alwaysSucceedingUnitOfWork() {
+ return ops -> ops.accept(new NoOpTransactionOperations());
+ }
+
+ private static UnitOfWorkPort throwingUnitOfWork(RuntimeException ex) {
+ return ops -> { throw ex; };
+ }
+
+ // -------------------------------------------------------------------------
+ // Testfall 1: Erfolgreicher Pfad ohne Konflikt
+ // -------------------------------------------------------------------------
+
+ @Test
+ void rename_delegatesToAllPortsAndReturnsSuccess_whenNoConflict() {
+ List
+ * Teilt die Wiring-Konventionen mit dem Batch-Pfad: SQLite-URL-Aufbau, Adapter-Instanzen
+ * und Logger-Konfiguration werden nach dem gleichen Muster erzeugt.
+ *
+ * @param startConfig die validierte Startkonfiguration; darf nicht null sein
+ * @return ein einsatzbereiter Use-Case; nie null
+ */
+ private ManualFileRenameUseCase buildProductionManualFileRenameUseCase(
+ StartConfiguration startConfig) {
+ String jdbcUrl = buildJdbcUrl(startConfig);
+ DocumentRecordRepository documentRecordRepository =
+ new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
+ UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
+ TargetFolderPort targetFolderPort =
+ new FilesystemTargetFolderAdapter(startConfig.targetFolder());
+ TargetFileRenamePort targetFileRenamePort =
+ new FilesystemTargetFileRenameAdapter(startConfig.targetFolder());
+ ClockPort clockPort = new SystemClockAdapter();
+ AiContentSensitivity aiContentSensitivity =
+ resolveAiContentSensitivity(startConfig.logAiSensitive());
+ ProcessingLogger processingLogger = new Log4jProcessingLogger(
+ DefaultManualFileRenameUseCase.class, aiContentSensitivity);
+ return new DefaultManualFileRenameUseCase(
+ documentRecordRepository,
+ targetFolderPort,
+ targetFileRenamePort,
+ unitOfWorkPort,
+ clockPort,
+ processingLogger);
+ }
+
+ /**
+ * Führt eine manuelle Umbenennung einer Zieldatei durch, ausgelöst von der GUI.
+ *
+ * Lädt und validiert die Konfiguration aus {@code configFilePath}, baut den
+ * Use-Case auf und delegiert die Umbenennung. Alle Fehler beim Laden oder
+ * Validieren der Konfiguration werden als strukturiertes {@link ManualFileRenameResult}
+ * zurückgegeben.
+ *
+ * @param configFilePath Pfad zur {@code .properties}-Datei; muss existieren
+ * @param request die Umbenennungsanfrage; darf nicht null sein
+ * @return das Ergebnis der Umbenennung; nie null
+ */
+ ManualFileRenameResult performGuiManualFileRename(
+ Path configFilePath,
+ ManualFileRenameRequest request) {
+ Objects.requireNonNull(configFilePath, "configFilePath must not be null");
+ Objects.requireNonNull(request, "request must not be null");
+ LOG.info("GUI-Umbenennung: Anfrage für Fingerprint={}, Zielname={}.",
+ request.fingerprint().sha256Hex(), request.desiredBaseFileName());
+
+ if (!Files.exists(configFilePath)) {
+ String msg = "Konfigurationsdatei nicht gefunden: " + configFilePath;
+ LOG.error("GUI-Umbenennung: {}", msg);
+ return new de.gecheckt.pdf.umbenenner.application.port.in
+ .ManualFileRenameFileSystemFailure(msg);
+ }
+
+ try {
+ migrateConfigurationIfNeeded(configFilePath);
+ StartConfiguration config = loadAndValidateConfiguration(configFilePath);
+ initializeSchema(config);
+ ManualFileRenameUseCase useCase = buildProductionManualFileRenameUseCase(config);
+ ManualFileRenameResult result = useCase.rename(request);
+ LOG.info("GUI-Umbenennung abgeschlossen: Ergebnis={}.", result.getClass().getSimpleName());
+ return result;
+ } catch (ConfigurationLoadingException e) {
+ LOG.error("GUI-Umbenennung: Konfiguration konnte nicht geladen werden: {}",
+ e.getMessage(), e);
+ return new de.gecheckt.pdf.umbenenner.application.port.in
+ .ManualFileRenamePersistenceFailure(
+ "Konfiguration konnte nicht geladen werden: " + e.getMessage());
+ } catch (InvalidStartConfigurationException e) {
+ LOG.error("GUI-Umbenennung: Konfiguration ist nicht lauffähig: {}", e.getMessage());
+ return new de.gecheckt.pdf.umbenenner.application.port.in
+ .ManualFileRenamePersistenceFailure(
+ "Die Konfiguration ist nicht lauffähig: " + e.getMessage());
+ } catch (DocumentPersistenceException e) {
+ LOG.error("GUI-Umbenennung: SQLite-Initialisierung fehlgeschlagen: {}",
+ e.getMessage(), e);
+ return new de.gecheckt.pdf.umbenenner.application.port.in
+ .ManualFileRenamePersistenceFailure(
+ "SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
+ } catch (RuntimeException e) {
+ LOG.error("GUI-Umbenennung: Unerwarteter Fehler: {}", e.getMessage(), e);
+ return new de.gecheckt.pdf.umbenenner.application.port.in
+ .ManualFileRenameFileSystemFailure(
+ "Unerwarteter Fehler: "
+ + (e.getMessage() == null
+ ? e.getClass().getSimpleName()
+ : e.getMessage()));
+ }
+ }
+
/**
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
* recorded as a failure with the given error message.
+ *
+ *
+ * Threading
+ * Layout
*
- * ┌──────────────────────────────────────────────────────┐
- * │ [Fortschrittsbalken] 12 / 47 Dateien │
- * ├──────────────────────────────────┬───────────────────┤
- * │ Ergebnisliste │ Seitenbereich │
- * │ (TableView mit Checkbox-Spalte) │ (Reasoning) │
- * ├──────────────────────────────────┴───────────────────┤
- * │ [Erneut verarbeiten] [Status zurücksetzen] │
- * ├──────────────────────────────────────────────────────┤
- * │ Meldungs- und Zusammenfassungsbereich │
- * ├──────────────────────────────────────────────────────┤
- * │ [Starten] [Abbrechen] │
- * └──────────────────────────────────────────────────────┘
+ * ┌──────────────────────────────────────────────────────────┐
+ * │ [Fortschrittsbalken] 12 / 47 Dateien │
+ * ├───────────────────────────┬──────────────────────────────┤
+ * │ Ergebnisliste (60%) │ Detailbereich (40%) │
+ * │ (TableView + Checkboxen) │ KI-Begründung (kompakt) │
+ * │ │ Dateiname-Editor │
+ * │ │ PDF-Vorschau (Restplatz) │
+ * ├───────────────────────────┴──────────────────────────────┤
+ * │ [Erneut verarbeiten] [Status zurücksetzen] │
+ * ├──────────────────────────────────────────────────────────┤
+ * │ Meldungs- und Zusammenfassungsbereich │
+ * ├──────────────────────────────────────────────────────────┤
+ * │ [Starten] [Abbrechen] │
+ * └──────────────────────────────────────────────────────────┘
*
*
* Threading
- * Threadingmodell
+ * Exception-Vertrag
+ * Fehlerfälle
+ *
+ *
+ *
+ * Threading
+ *
+ *
+ *
+ * @param oldFileName der aktuell im Zielordner vorhandene Dateiname (ohne Pfad);
+ * darf nicht null sein
+ * @param newFileName der gewünschte neue Dateiname (ohne Pfad); darf nicht null sein
+ * @return das Ergebnis der Umbenennung; nie null
+ */
+ @Override
+ public TargetFileRenameResult rename(String oldFileName, String newFileName) {
+ Objects.requireNonNull(oldFileName, "oldFileName darf nicht null sein");
+ Objects.requireNonNull(newFileName, "newFileName darf nicht null sein");
+
+ Path oldPath = targetFolder.resolve(oldFileName);
+ Path newPath = targetFolder.resolve(newFileName);
+
+ if (Files.notExists(oldPath)) {
+ LOG.warn("Umbenennung verweigert: Quelldatei nicht vorhanden: '{}'", oldPath);
+ return new TargetFileRenameFailureFileNotFound(oldFileName);
+ }
+
+ if (Files.exists(newPath) && !oldPath.equals(newPath)) {
+ LOG.warn("Umbenennung verweigert: Zieldatei bereits vorhanden: '{}'", newPath);
+ return new TargetFileRenameFailureTargetExists(newFileName);
+ }
+
+ try {
+ try {
+ Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException atomicEx) {
+ LOG.warn("Atomares Verschieben nicht unterstützt (z. B. Netzlaufwerk) für '{}' → '{}'. " +
+ "Rückfall auf normales Verschieben.", oldPath, newPath);
+ Files.move(oldPath, newPath);
+ }
+ LOG.info("Datei erfolgreich umbenannt: '{}' → '{}'", oldFileName, newFileName);
+ return new TargetFileRenameSuccess();
+ } catch (IOException e) {
+ String message = "Technischer Fehler beim Umbenennen von '" + oldFileName
+ + "' zu '" + newFileName + "': " + e.getMessage();
+ LOG.error(message, e);
+ return new TargetFileRenameTechnicalFailure(message);
+ }
+ }
+}
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/package-info.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/package-info.java
index 72e6b15..a28c25d 100644
--- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/package-info.java
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/targetfolder/package-info.java
@@ -6,6 +6,9 @@
*
+ *
+ *
+ * @param reason menschenlesbare Begründung für den ungültigen Zustand; nie null
+ */
+public record ManualFileRenameInvalidState(String reason) implements ManualFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung des Pflichtfelds.
+ *
+ * @throws NullPointerException wenn {@code reason} null ist
+ */
+ public ManualFileRenameInvalidState {
+ Objects.requireNonNull(reason, "reason must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameNoOpIdenticalTarget.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameNoOpIdenticalTarget.java
new file mode 100644
index 0000000..ab99f79
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameNoOpIdenticalTarget.java
@@ -0,0 +1,32 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis, wenn keine Umbenennung notwendig ist, weil die Zieldatei mit dem
+ * gewünschten Namen bereits vorhanden ist und denselben Inhalt hat (gleicher Fingerprint).
+ *
+ *
+ * Weder Dateisystem noch Persistenz werden in diesem Fall verändert.
+ *
+ * @param existingFileName der Dateiname (ohne Pfad) der bereits vorhandenen identischen
+ * Datei; nie null
+ */
+public record ManualFileRenameNoOpIdenticalTarget(String existingFileName) implements ManualFileRenameResult {
+
+ /**
+ * Kompakter Konstruktor zur Validierung des Pflichtfelds.
+ *
+ * @throws NullPointerException wenn {@code existingFileName} null ist
+ */
+ public ManualFileRenameNoOpIdenticalTarget {
+ Objects.requireNonNull(existingFileName, "existingFileName must not be null");
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenamePersistenceFailure.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenamePersistenceFailure.java
new file mode 100644
index 0000000..5b880e4
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenamePersistenceFailure.java
@@ -0,0 +1,29 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis, wenn die Persistenzaktualisierung nach erfolgreicher Dateisystem-Umbenennung
+ * fehlgeschlagen ist.
+ *
+ *
+ */
+public sealed interface ManualFileRenameResult
+ permits ManualFileRenameSuccess,
+ ManualFileRenameNoOpIdenticalTarget,
+ ManualFileRenameDocumentNotFound,
+ ManualFileRenameInvalidState,
+ ManualFileRenameSourceFileMissing,
+ ManualFileRenameFileSystemFailure,
+ ManualFileRenamePersistenceFailure {
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameSourceFileMissing.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameSourceFileMissing.java
new file mode 100644
index 0000000..5f26b49
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/ManualFileRenameSourceFileMissing.java
@@ -0,0 +1,29 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.util.Objects;
+
+/**
+ * Ergebnis, wenn die bisherige Zieldatei im Zielordner nicht mehr vorhanden ist.
+ *
+ *
+ */
+public interface ManualFileRenameUseCase {
+
+ /**
+ * Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
+ *
+ *
+ */
+public sealed interface TargetFileRenameResult
+ permits TargetFileRenameSuccess,
+ TargetFileRenameFailureFileNotFound,
+ TargetFileRenameFailureTargetExists,
+ TargetFileRenameTechnicalFailure {
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameSuccess.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameSuccess.java
new file mode 100644
index 0000000..5a9c25c
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/TargetFileRenameSuccess.java
@@ -0,0 +1,10 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+/**
+ * Ergebnis einer erfolgreichen Umbenennung einer Zieldatei via {@link TargetFileRenamePort}.
+ *
+ *
+ *