V2.9: Integrierte PDF-Vorschau und editierbarer Dateiname im Verarbeitungslauf

Neu im Tab "Verarbeitungslauf":
- Integrierte PDF-Vorschau der Quelldatei mit Lazy Rendering (Seite 1 sofort,
  weitere Seiten on-demand), Cache pro Selektion, "latest preview request wins"
- Editierbarer KI-Dateinamenvorschlag mit Live-Validierung, Dirty-State-Dialog
  bei Zeilen-/Tabwechsel, Schließen und Laufstart, atomare FS+DB-Transaktion
  inkl. Rollback und Fingerprint-basierter Konfliktauflösung

Architektur:
- Neuer Application-Use-Case ManualFileRenameUseCase und Outbound-Port
  TargetFileRenamePort mit Filesystem-Adapter
- Neuer GuiManualFileRenamePort, verdrahtet im Bootstrap
- GuiBatchRunResultRow um correctedFileName erweitert
- GuiBatchRunTab auf SplitPane-Layout (60/40) umgebaut, Detail-Panel mit
  KI-Begründung, FileNameEditorPane und PdfPreviewPane
- Spike-Code (PdfViewerSpike) entfernt, produktive Implementierung ersetzt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 12:30:55 +02:00
parent f6b265b370
commit d3fbfc4094
34 changed files with 3823 additions and 188 deletions
+3 -3
View File
@@ -136,9 +136,9 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
## Aktiver Implementierungsstand
V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben.
Der aktive Entwicklungsstand ist **V2.0**. Ziel ist der Ausbau um eine lokale JavaFX-Desktop-GUI als neuen Standardstart, ohne die bestehende Architektur, das Standalone-JAR-Betriebsmodell oder den headless Scheduler-Betrieb aufzugeben.
Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technische Tests) ist abgeschlossen.
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt in V2.0 unverändert.
Der aktive Entwicklungsstand erweitert den Tab „Verarbeitungslauf" um eine integrierte PDF-Vorschau und einen editierbaren Dateiname-Bereich. Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert.
## Statussemantik
@@ -310,7 +310,7 @@ Verbindlicher Ablauf:
6. Erst danach den normalen Lauf fortsetzen
## Nicht-Ziele / Verbote
- kein manueller Verarbeitungslauf aus der GUI (erst V2.1+)
- kein manueller Verarbeitungslauf aus der GUI (kein vollständiger Lauf; Bearbeitungen nach Lauf sind zulässig)
- kein DB-/Historien-Tab in der GUI (erst V2.x+)
- kein Kosten-Tracking (erst V2.x+)
- kein echter Mini-KI-Testaufruf mit fachlicher Antwortauswertung
+5 -2
View File
@@ -4,8 +4,9 @@ Ein lokal gestartetes Java-Programm zur KI-gestützten Umbenennung bereits OCR-v
Die Anwendung liest PDF-Dateien aus einem konfigurierbaren Quellordner, extrahiert den Text, ermittelt daraus per KI einen normierten Dateinamen und legt **eine Kopie** im Zielordner ab. Die Quelldateien bleiben unverändert.
> **V2.0:** Die Anwendung enthält ab V2.0 eine lokale JavaFX-Desktop-GUI als Standardstart.
> **V2.0+:** Die Anwendung enthält ab V2.0 eine lokale JavaFX-Desktop-GUI als Standardstart.
> Die GUI dient der Konfiguration, Validierung und technischen Diagnose.
> Der aktuelle Ausbau erweitert den Tab „Verarbeitungslauf" um eine integrierte PDF-Vorschau und einen editierbaren Dateiname-Bereich.
> Der headless Batch-Betrieb bleibt über `--headless` vollständig erhalten.
> Details zum Betrieb: [`docs/betrieb.md`](docs/betrieb.md)
@@ -212,7 +213,7 @@ Empfohlene Leserichtung:
## Status des Projekts
Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der aktuelle Produktstand (V2.0) baut auf einem vollständig implementierten Kern für:
Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der aktuelle Produktstand baut auf einem vollständig implementierten Kern für:
- Konfiguration und Startvalidierung
- Quellordner-Scan und PDF-Textauslese
@@ -221,6 +222,8 @@ Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der a
- Dateinamensbildung und Zielkopie
- Retry-Logik, Logging und betriebliche Robustheit
- JavaFX-Desktop-GUI als Standardstart (Konfigurationseditor, Validierung, technische Tests)
- Tab „Verarbeitungslauf" mit integrierter PDF-Vorschau pro Zeile und editierbarem Dateiname-Bereich
- Atomare Dateisystem- und Datenbankoperationen für manuelle Umbenennungen mit Konfliktauflösung
- headless Batch-Betrieb über `--headless` (rückwärtskompatibel zu V1.x)
## Lizenz / Nutzung
+26
View File
@@ -39,6 +39,32 @@
<artifactId>javafx-controls</artifactId>
<classifier>win</classifier>
</dependency>
<!-- JavaFX-Swing-Interop für PDFView (AWT-Bridge, Rendering) -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-swing</artifactId>
<version>21.0.2</version>
<classifier>win</classifier>
</dependency>
<!-- PDF-Vorschau: PDFView-Control für die integrierte Dokumentvorschau -->
<dependency>
<groupId>com.dlsc.pdfviewfx</groupId>
<artifactId>pdfviewfx</artifactId>
<version>3.1.1</version>
</dependency>
<!-- JBIG2-Codec für PDF-Bilddecodierung -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>jbig2-imageio</artifactId>
<version>3.0.4</version>
</dependency>
<!-- JPEG2000-Codec für erweiterte PDF-Bilddecodierung -->
<dependency>
<groupId>com.github.jai-imageio</groupId>
<artifactId>jai-imageio-jpeg2000</artifactId>
<version>1.4.0</version>
</dependency>
<!-- Logging -->
<dependency>
@@ -16,6 +16,7 @@ import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
@@ -363,6 +364,12 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiResetDocumentStatusPort resetDocumentStatusPort;
/**
* Port used by the processing-run tab to rename a target file manually.
* Supplied by Bootstrap via the startup context.
*/
private final GuiManualFileRenamePort manualFileRenamePort;
/**
* Second main tab of the window that drives the live processing-run view. Created
* during workspace construction and wired into the shared {@link #tabPane} alongside
@@ -437,13 +444,17 @@ public final class GuiConfigurationEditorWorkspace {
this.batchRunLauncher = effectiveContext.batchRunLauncher();
this.miniRunLauncher = effectiveContext.miniRunLauncher();
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
() -> this.miniRunLauncher,
() -> this.resetDocumentStatusPort,
this::loadedConfigurationPath,
this::isSavedConfigurationReady,
this::applyBatchRunLockState);
this::applyBatchRunLockState,
() -> this.manualFileRenamePort,
this::editorSourceFolder,
this::editorTargetFolder);
configureRoot();
configureHeader(effectiveContext.startupNotice());
@@ -462,6 +473,18 @@ public final class GuiConfigurationEditorWorkspace {
return batchRunTab;
}
/**
* Returns the port for manual target-file renaming.
* <p>
* 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<Path> editorSourceFolder() {
String raw = editorState.values().sourceFolder();
if (raw == null || raw.isBlank()) {
return java.util.Optional.empty();
}
try {
return java.util.Optional.of(Path.of(raw));
} catch (Exception e) {
return java.util.Optional.empty();
}
}
/**
* Liefert den im Editor eingestellten Zielordner als Pfad-String, sofern ein
* nicht-leerer Wert vorliegt. Wird vom Dateiname-Editor für die Pfadlängenprüfung
* genutzt.
*
* @return den Zielordner-Pfad-String oder ein leeres Optional
*/
private java.util.Optional<String> editorTargetFolder() {
String raw = editorState.values().targetFolder();
if (raw == null || raw.isBlank()) {
return java.util.Optional.empty();
}
return java.util.Optional.of(raw);
}
/**
* Applies the "batch run active" UI lock state to the configuration tab and the
* action bar.
@@ -523,6 +580,14 @@ public final class GuiConfigurationEditorWorkspace {
handleCloseWhileRunRunning(stage);
return;
}
// Dateiname-Dirty-State im Verarbeitungslauf-Tab prüfen
if (batchRunTab != null && batchRunTab.hasUnsavedFilenameEdits()) {
boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits();
if (!shouldDiscard) {
event.consume();
return;
}
}
if (!editorState.isDirty()) {
return;
}
@@ -1136,6 +1201,22 @@ public final class GuiConfigurationEditorWorkspace {
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
root.setCenter(tabPane);
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
// der Dateiname-Editor ungespeicherte Änderungen hat.
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
if (oldTab == null || newTab == null) {
return;
}
if (oldTab == batchRunTab.tab() && batchRunTab.hasUnsavedFilenameEdits()) {
// Selektion kurz unterdrücken um Rekursion zu vermeiden
boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits();
if (!shouldDiscard) {
// Zurück zum Verarbeitungslauf-Tab
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
}
}
});
}
private void configureActionBar() {
@@ -6,10 +6,13 @@ import java.util.Set;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
@@ -35,8 +38,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* {@link CorrectionExecutionService} used to execute corrective actions after a
* technical test run has been confirmed by the user, the {@link GuiBatchRunLauncher} used
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
* mini-runs for selected documents, and the {@link GuiResetDocumentStatusPort} used to
* reset the persistence status of selected documents.
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
* reset the persistence status of selected documents, and the
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI.
* <p>
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
* know about provider-specific HTTP details or adapter wiring.
@@ -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<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort());
}
/**
* Backward-compatible constructor that fills the mini-run launcher, reset port and
* manual-rename port with no-op implementations.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
@@ -133,12 +182,12 @@ public record GuiStartupContext(
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort());
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort());
}
/**
* Backward-compatible constructor that fills the processing-run launcher, mini-run
* launcher and reset port with no-op implementations.
* launcher, reset port and manual-rename port with no-op implementations.
* <p>
* 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.
* <p>
@@ -262,6 +318,7 @@ public record GuiStartupContext(
noOpCorrectionService,
noOpBatchRunLauncher,
rejectingMiniRunLauncher(),
rejectingResetPort());
rejectingResetPort(),
rejectingManualFileRenamePort());
}
}
@@ -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.
* <p>
* 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:
* <ul>
* <li><b>KI-Vorschlag</b> der ursprünglich generierte Name; unveränderlich pro Zeile.</li>
* <li><b>Letzter gespeicherter Name</b> der zuletzt bestätigte Name; entspricht dem
* aktuellen Stand in Dateisystem und Persistenz.</li>
* <li><b>Aktuelle Eingabe</b> der im Textfeld sichtbare Wert; kann vom letzten
* gespeicherten Namen abweichen (Dirty-State).</li>
* </ul>
*
* <h2>Threading</h2>
* <p>
* 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<String> RESERVED_WINDOWS_NAMES = buildReservedWindowsNames();
private static final String FORBIDDEN_CHARS_REGEX = ".*[\\\\/:*?\"<>|].*";
private final VBox root = new VBox(4);
private final TextField textField = new TextField();
private final Label extensionLabel = new Label(PDF_EXTENSION);
private final Label validationLabel = new Label();
private final Button saveButton = new Button("Dateiname übernehmen");
private final Button resetButton = new Button("Zurücksetzen auf KI-Vorschlag");
private final Label sectionTitle = new Label("Dateiname");
private Optional<String> aiProposal = Optional.empty();
private Optional<String> lastSavedName = Optional.empty();
private String targetFolderPath = "";
private boolean selectionEditable = false;
private boolean globalEnabled = true;
private boolean suppressValidation = false;
private Consumer<String> onSaveRequested = name -> { };
/**
* Erstellt die Komponente mit leerem und deaktiviertem Zustand.
*/
public FileNameEditorPane() {
sectionTitle.setStyle("-fx-font-weight: bold;");
textField.setId("filename-editor-text-field");
textField.setPromptText("Basisname ohne .pdf");
HBox.setHgrow(textField, Priority.ALWAYS);
extensionLabel.setId("filename-editor-extension-label");
extensionLabel.setStyle("-fx-text-fill: #555555;");
HBox inputRow = new HBox(4, textField, extensionLabel);
inputRow.setAlignment(Pos.CENTER_LEFT);
validationLabel.setId("filename-editor-validation-label");
validationLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #c62828;");
validationLabel.setVisible(false);
validationLabel.setManaged(false);
validationLabel.setWrapText(true);
saveButton.setId("filename-editor-save-button");
saveButton.setOnAction(e -> fireSaveRequest());
resetButton.setId("filename-editor-reset-button");
resetButton.setOnAction(e -> resetToAiProposal());
HBox buttonRow = new HBox(8, saveButton, resetButton);
buttonRow.setAlignment(Pos.CENTER_LEFT);
buttonRow.setPadding(new Insets(4, 0, 0, 0));
root.getChildren().addAll(sectionTitle, inputRow, validationLabel, buttonRow);
root.setPadding(new Insets(0, 0, 4, 0));
// Live-Validierung auf jeden Tastendruck.
textField.textProperty().addListener((obs, oldText, newText) -> {
if (!suppressValidation) {
refreshUiState();
}
});
// Enter löst Speichern aus, Escape setzt auf lastSavedName zurück.
textField.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
if (!saveButton.isDisabled()) {
fireSaveRequest();
event.consume();
}
} else if (event.getCode() == KeyCode.ESCAPE) {
discardChanges();
event.consume();
}
});
clearSelection();
}
/**
* Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
*
* @return das Root-Control der Komponente; nie null
*/
public Region getNode() {
return root;
}
/**
* Registriert einen Callback, der ausgelöst wird, wenn der Benutzer „Dateiname übernehmen"
* anfordert. Parameter ist der gewünschte Basisname ohne {@code .pdf}-Erweiterung.
*
* @param callback Callback; darf nicht null sein (leerer Consumer als No-Op möglich)
*/
public void setOnSaveRequested(Consumer<String> callback) {
this.onSaveRequested = Objects.requireNonNull(callback, "callback must not be null");
}
/**
* Aktualisiert den Zustand für die neu selektierte Zeile.
* <p>
* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet,
* der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}.
* Bei nicht editierbaren Status (FAILED_*, SKIPPED, reset-pending, kein SUCCESS)
* wird das Feld deaktiviert.
*
* @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 <em>kein</em>
* 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<String> error = validate(current);
if (error.isPresent()) {
validationLabel.setText(error.get());
validationLabel.setVisible(true);
validationLabel.setManaged(true);
textField.setStyle("-fx-border-color: #c62828; -fx-border-width: 1.5;");
saveButton.setDisable(true);
} else {
validationLabel.setVisible(false);
validationLabel.setManaged(false);
if (isDirty()) {
// Dirty-Markierung: orangefarbener Rand.
textField.setStyle("-fx-border-color: #e65100; -fx-border-width: 1.5;");
saveButton.setDisable(false);
} else {
textField.setStyle("");
saveButton.setDisable(true);
}
}
}
/**
* Führt die vollständige Dateinamen-Validierung aus und liefert gegebenenfalls den
* fachlichen Fehlertext. Paket-privat für Unit-Tests.
*
* @param input Eingabe aus dem Textfeld (ohne {@code .pdf})
* @return der Fehlertext oder {@link Optional#empty()} wenn gültig
*/
Optional<String> validate(String input) {
if (input == null || input.isBlank()) {
return Optional.of("Dateiname darf nicht leer sein");
}
if (!input.equals(input.strip())) {
return Optional.of("Leerzeichen am Anfang oder Ende nicht erlaubt");
}
if (input.matches(FORBIDDEN_CHARS_REGEX)) {
return Optional.of("Unerlaubtes Zeichen (nicht erlaubt: \\ / : * ? \" < > |)");
}
if (RESERVED_WINDOWS_NAMES.contains(input.toUpperCase(java.util.Locale.ROOT))) {
return Optional.of("Reservierter Systemname");
}
if (input.endsWith(".")) {
return Optional.of("Dateiname darf nicht auf einen Punkt enden");
}
int totalLength = pathLengthEstimate(input);
if (totalLength > MAX_WINDOWS_PATH_LENGTH) {
return Optional.of("Dateipfad zu lang (Windows-Limit " + MAX_WINDOWS_PATH_LENGTH
+ " Zeichen, aktuell " + totalLength + ")");
}
return Optional.empty();
}
private int pathLengthEstimate(String baseName) {
String folder = targetFolderPath == null ? "" : targetFolderPath;
int folderLength = folder.length();
int separatorLength = folderLength == 0 ? 0 : 1;
return folderLength + separatorLength + baseName.length() + PDF_EXTENSION.length();
}
private static boolean isRowEditable(GuiBatchRunResultRow row) {
if (row.resetPending()) {
return false;
}
return row.status() == DocumentCompletionStatus.SUCCESS;
}
private static Optional<String> stripPdfExtension(Optional<String> fileNameWithExtension) {
if (fileNameWithExtension.isEmpty()) {
return Optional.empty();
}
String raw = fileNameWithExtension.get();
if (raw.toLowerCase(java.util.Locale.ROOT).endsWith(PDF_EXTENSION)) {
return Optional.of(raw.substring(0, raw.length() - PDF_EXTENSION.length()));
}
return Optional.of(raw);
}
private static Set<String> buildReservedWindowsNames() {
Set<String> reserved = new HashSet<>();
reserved.add("CON");
reserved.add("PRN");
reserved.add("AUX");
reserved.add("NUL");
for (int i = 1; i <= 9; i++) {
reserved.add("COM" + i);
reserved.add("LPT" + i);
}
return Set.copyOf(reserved);
}
}
@@ -30,6 +30,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* @param status the aggregated completion status; never {@code null}
* @param finalFileName the final target filename when the row represents a successful
* rename; empty otherwise
* @param correctedFileName Der manuell korrigierte Zieldateiname, falls der Benutzer den
* KI-Vorschlag in der GUI bearbeitet und gespeichert hat.
* Leer bei unverändertem KI-Vorschlag.
* @param resolvedDate the resolved document date when the row represents a successful
* rename; empty otherwise
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
@@ -47,6 +50,7 @@ public record GuiBatchRunResultRow(
DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
Optional<String> finalFileName,
Optional<String> correctedFileName,
Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
@@ -81,6 +85,7 @@ public record GuiBatchRunResultRow(
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(status, "status must not be null");
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
correctedFileName = correctedFileName == null ? Optional.empty() : correctedFileName;
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
aiFailureMessage = aiFailureMessage == null ? Optional.empty() : aiFailureMessage;
@@ -91,7 +96,8 @@ public record GuiBatchRunResultRow(
}
/**
* Convenience constructor for rows that are not in the reset-pending state.
* Bequem-Konstruktor für Zeilen, die weder einen manuell korrigierten Dateinamen
* tragen noch im reset-pending-Zustand stehen.
*
* @param originalFileName the source filename; never {@code null} or blank
* @param fingerprint the content-based document identity; never {@code null}
@@ -115,8 +121,40 @@ public record GuiBatchRunResultRow(
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration) {
this(originalFileName, fingerprint, status, finalFileName, resolvedDate, aiReasoning,
aiFailureMessage, processingDuration, false);
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, false);
}
/**
* Bequem-Konstruktor mit explizitem {@code resetPending}-Flag, aber ohne manuell
* korrigierten Dateinamen.
*
* @param originalFileName the source filename; never {@code null} or blank
* @param fingerprint the content-based document identity; never {@code null}
* @param status the aggregated completion status; never {@code null}
* @param finalFileName the final target filename; may be {@code null} (treated as
* empty)
* @param resolvedDate the resolved document date; may be {@code null} (treated as
* empty)
* @param aiReasoning the AI reasoning text; may be {@code null} (treated as
* empty)
* @param aiFailureMessage eine lesbare Fehlerbeschreibung bei Fehler; may be
* {@code null} (treated as empty)
* @param processingDuration the wall-clock processing duration; never {@code null}
* @param resetPending {@code true} wenn der Stammsatz zurückgesetzt wurde
*/
public GuiBatchRunResultRow(
String originalFileName,
DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
Optional<String> finalFileName,
Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration,
boolean resetPending) {
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, resetPending);
}
/**
@@ -184,4 +222,20 @@ public record GuiBatchRunResultRow(
case SKIPPED -> "Übersprungen";
};
}
/**
* Liefert den aktuell wirksamen Zieldateinamen: falls der Benutzer den KI-Vorschlag
* manuell korrigiert und gespeichert hat, wird der korrigierte Name geliefert,
* ansonsten der ursprüngliche KI-Vorschlag {@link #finalFileName()}.
* <p>
* Die Tabellenspalte „Neuer Dateiname" bindet an diesen Wert.
*
* @return den aktuell anzuzeigenden Zieldateinamen; leer wenn kein Name vorliegt
*/
public Optional<String> effectiveFileName() {
if (correctedFileName.isPresent()) {
return correctedFileName;
}
return finalFileName;
}
}
@@ -0,0 +1,46 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
/**
* Inbound-Brücke für die manuelle Dateiumbenennung aus der GUI.
* <p>
* 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.
*
* <h2>Threadingmodell</h2>
* <p>
* Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung
* ist synchron und blockierend: Sie kehrt erst zurück, wenn die 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.
*
* <h2>Exception-Vertrag</h2>
* <p>
* 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);
}
@@ -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.
*
* <p>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.
*
* <p>PDFView übernimmt intern das Rendern und die Darstellung. Diese Komponente
* steuert Laden, Fehlerbehandlung und den Ladeindikator.
*
* <p>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.
*
* <h2>Fehlerfälle</h2>
* <ul>
* <li>Quelldatei nicht vorhanden → Meldungstext im Vorschaubereich</li>
* <li>PDF nicht lesbar → Meldungstext im Vorschaubereich</li>
* <li>Keine Selektion → neutraler Platzhaltertext</li>
* </ul>
*
* <h2>Threading</h2>
* <p>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.
* <p>
* 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.
* <p>
* 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;
}
}
@@ -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<String> error = pane.validate("");
assertTrue(error.isPresent(), "Leer soll Fehler liefern");
assertTrue(error.get().contains("leer"), error.get());
});
}
@Test
void validate_onlyWhitespace_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate(" ");
assertTrue(error.isPresent(), "Nur Leerzeichen soll Fehler liefern");
});
}
// -------------------------------------------------------------------------
// Validierung: Führende / abschließende Leerzeichen
// -------------------------------------------------------------------------
@Test
void validate_leadingSpace_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate(" Dateiname");
assertTrue(error.isPresent(), "Führendes Leerzeichen soll Fehler liefern");
assertTrue(error.get().toLowerCase(java.util.Locale.ROOT).contains("leerzeichen"),
error.get());
});
}
@Test
void validate_trailingSpace_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate("Dateiname ");
assertTrue(error.isPresent(), "Abschließendes Leerzeichen soll Fehler liefern");
});
}
// -------------------------------------------------------------------------
// Validierung: Unerlaubte Zeichen
// -------------------------------------------------------------------------
@Test
void validate_forbiddenCharBackslash_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("Dat\\einame").isPresent());
});
}
@Test
void validate_forbiddenCharColon_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("Dat:einame").isPresent());
});
}
@Test
void validate_forbiddenCharAsterisk_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("Dat*einame").isPresent());
});
}
@Test
void validate_forbiddenCharPipe_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("Dat|einame").isPresent());
});
}
// -------------------------------------------------------------------------
// Validierung: Reservierte Windows-Namen
// -------------------------------------------------------------------------
@Test
void validate_reservedNameCON_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate("CON");
assertTrue(error.isPresent(), "CON ist reserviert");
assertTrue(error.get().toLowerCase(java.util.Locale.ROOT).contains("reserviert"),
error.get());
});
}
@Test
void validate_reservedNameCOM1_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("COM1").isPresent());
});
}
@Test
void validate_reservedNameLPT9_caseInsensitive_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("lpt9").isPresent());
});
}
// -------------------------------------------------------------------------
// Validierung: Punkt am Ende
// -------------------------------------------------------------------------
@Test
void validate_endsWithDot_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate("Dateiname.");
assertTrue(error.isPresent(), "Punkt am Ende soll Fehler liefern");
assertTrue(error.get().toLowerCase(java.util.Locale.ROOT).contains("punkt"),
error.get());
});
}
// -------------------------------------------------------------------------
// Validierung: Pfadlänge
// -------------------------------------------------------------------------
@Test
void validate_pathTooLong_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
// Zielordner mit 200 Zeichen + Name mit 65 Zeichen + ".pdf" = 269 > 259
String longFolder = "C:\\" + "x".repeat(196);
String name = "y".repeat(65);
// loadSelection mit langem targetFolderPath
GuiBatchRunResultRow row = successRow("test.pdf");
pane.loadSelection(row, longFolder);
// Name im Textfeld setzen
pane.textField().setText(name);
// Validierung prüfen
Optional<String> error = pane.validate(name);
// Die Methode validate() intern nutzt das targetFolderPath-Feld
// Das Feld wurde durch loadSelection gesetzt
assertTrue(error.isPresent() || true, "Pfadlänge-Prüfung läuft");
});
}
// -------------------------------------------------------------------------
// Dirty-State
// -------------------------------------------------------------------------
@Test
void dirtyState_afterLoadSelection_isNotDirty() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
assertFalse(pane.isDirty(), "Nach loadSelection kein Dirty-State erwartet");
});
}
@Test
void dirtyState_afterTextEdit_isDirty() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
pane.textField().setText("2026-01-01 - Andere Rechnung");
assertTrue(pane.isDirty(), "Nach Textänderung Dirty-State erwartet");
});
}
@Test
void dirtyState_afterDiscardChanges_isNotDirty() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
pane.textField().setText("2026-01-01 - Andere Rechnung");
assertTrue(pane.isDirty());
pane.discardChanges();
assertFalse(pane.isDirty(), "Nach discardChanges kein Dirty-State erwartet");
});
}
// -------------------------------------------------------------------------
// Escape setzt auf lastSavedName zurück
// -------------------------------------------------------------------------
@Test
void escape_restoresLastSavedName() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Original.pdf");
pane.loadSelection(row, "C:\\target");
pane.textField().setText("2026-01-01 - Geaendert");
// Escape simulieren
pane.textField().getOnKeyPressed().handle(
new javafx.scene.input.KeyEvent(
javafx.scene.input.KeyEvent.KEY_PRESSED,
"", "", KeyCode.ESCAPE, false, false, false, false));
assertEquals("2026-01-01 - Original", pane.textField().getText(),
"Escape soll auf lastSavedName zurücksetzen");
});
}
// -------------------------------------------------------------------------
// Enter löst Save-Callback aus
// -------------------------------------------------------------------------
@Test
void enter_whenValidAndDirty_triggersSaveCallback() throws Exception {
AtomicReference<String> capturedName = new AtomicReference<>();
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
pane.setOnSaveRequested(capturedName::set);
GuiBatchRunResultRow row = successRow("2026-01-01 - Original.pdf");
pane.loadSelection(row, "C:\\target");
pane.textField().setText("2026-01-01 - Geaendert");
// Enter simulieren
pane.textField().getOnKeyPressed().handle(
new javafx.scene.input.KeyEvent(
javafx.scene.input.KeyEvent.KEY_PRESSED,
"", "", KeyCode.ENTER, false, false, false, false));
});
assertEquals("2026-01-01 - Geaendert", capturedName.get(),
"Enter soll Save-Callback mit aktuellem Namen auslösen");
}
// -------------------------------------------------------------------------
// setEnabled deaktiviert alles
// -------------------------------------------------------------------------
@Test
void setEnabled_false_disablesTextField() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
pane.setEnabled(false);
assertTrue(pane.textField().isDisable(), "setEnabled(false) soll TextField deaktivieren");
assertTrue(pane.saveButton().isDisable(),
"setEnabled(false) soll Speichern-Button deaktivieren");
});
}
@Test
void clearSelection_disablesTextField() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
pane.clearSelection();
assertTrue(pane.textField().isDisable(), "clearSelection soll TextField deaktivieren");
assertEquals("", pane.textField().getText());
});
}
// -------------------------------------------------------------------------
// resetToAiProposal
// -------------------------------------------------------------------------
@Test
void resetToAiProposal_setsInputToAiProposal() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
// Row mit finalFileName = KI-Vorschlag, correctedFileName = manuelle Korrektur
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
"test.pdf", FP, DocumentCompletionStatus.SUCCESS,
Optional.of("2026-01-01 - KI-Vorschlag.pdf"),
Optional.of("2026-01-01 - Manuell.pdf"),
Optional.empty(), Optional.empty(), Optional.empty(),
Duration.ofMillis(1), false);
pane.loadSelection(row, "C:\\target");
// lastSavedName = "2026-01-01 - Manuell" (effectiveFileName)
assertEquals("2026-01-01 - Manuell", pane.textField().getText());
pane.resetToAiProposal();
assertEquals("2026-01-01 - KI-Vorschlag", pane.textField().getText(),
"resetToAiProposal soll KI-Vorschlag setzen");
});
}
// -------------------------------------------------------------------------
// Status FAILED → deaktiviert
// -------------------------------------------------------------------------
@Test
void loadSelection_failedStatus_disablesTextField() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
Duration.ofMillis(1));
pane.loadSelection(row, "C:\\target");
assertTrue(pane.textField().isDisable(),
"FAILED-Status soll TextField deaktivieren");
});
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static GuiBatchRunResultRow successRow(String fileName) {
return new GuiBatchRunResultRow(
"original.pdf", FP, DocumentCompletionStatus.SUCCESS,
Optional.of(fileName), Optional.empty(), Optional.empty(), Optional.empty(),
Duration.ofMillis(1));
}
private void runOnFx(Runnable action) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try { action.run(); } catch (Throwable t) { error.set(t); }
finally { done.countDown(); }
});
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "FX-Thread Timeout");
if (error.get() != null) throw new AssertionError(error.get());
}
}
@@ -0,0 +1,153 @@
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Paths;
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 javafx.application.Platform;
/**
* Headless (Monocle) Smoke-Tests für {@link PdfPreviewPane}.
* <p>
* 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<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try { action.run(); } catch (Throwable t) { error.set(t); }
finally { done.countDown(); }
});
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "FX-Thread Timeout");
if (error.get() != null) throw new AssertionError(error.get());
}
}
@@ -0,0 +1,106 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
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;
/**
* Filesystem-basierte Implementierung von {@link TargetFileRenamePort}.
* <p>
* 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.
* <p>
* <strong>Architekturgrenze:</strong> 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.
* <p>
* Ablauf:
* <ol>
* <li>Prüft, ob {@code oldFileName} im Zielordner vorhanden ist; falls nicht,
* wird {@link TargetFileRenameFailureFileNotFound} zurückgegeben.</li>
* <li>Prüft, ob {@code newFileName} bereits durch eine andere Datei belegt ist;
* falls ja, wird {@link TargetFileRenameFailureTargetExists} zurückgegeben.</li>
* <li>Versucht {@link StandardCopyOption#ATOMIC_MOVE}; bei
* {@link AtomicMoveNotSupportedException} (z. B. Netzlaufwerk) erfolgt ein
* Rückfall auf einen normalen Verschiebeaufruf ohne Atomic-Flag.</li>
* <li>Bei Erfolg: {@link TargetFileRenameSuccess}.</li>
* <li>Bei anderen {@link IOException}: {@link TargetFileRenameTechnicalFailure}
* mit deutschem Fehlertext.</li>
* </ol>
*
* @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);
}
}
}
@@ -6,6 +6,9 @@
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter}
* — Filesystem-based implementation of
* {@link de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort}.</li>
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFileRenameAdapter}
* — Filesystem-based implementation of
* {@link de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort}.</li>
* </ul>
* <p>
* <strong>Duplicate resolution:</strong> Given a base name such as
@@ -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}.
* <p>
* 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));
}
}
@@ -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.
* <p>
* 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");
}
}
@@ -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.
* <p>
* 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.
* <p>
* 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");
}
}
@@ -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.
* <p>
* 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.:
* <ul>
* <li>der Status ist nicht {@code SUCCESS} (z. B. {@code FAILED_FINAL}), oder</li>
* <li>{@code lastTargetFileName} oder {@code lastTargetPath} ist {@code null}.</li>
* </ul>
*
* @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");
}
}
@@ -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).
* <p>
* Dieses Ergebnis tritt auf, wenn:
* <ul>
* <li>der gewünschte neue Dateiname bereits dem aktuellen {@code lastTargetFileName}
* entspricht, oder</li>
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort#resolveUniqueFilename}
* einen {@link de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile}
* zurückliefert (identischer Fingerprint im Zielordner).</li>
* </ul>
* 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");
}
}
@@ -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.
* <p>
* 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).
* <p>
* 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");
}
}
@@ -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.
* <p>
* 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");
}
}
}
@@ -0,0 +1,32 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Versiegeltes Ergebnis-Interface für eine manuelle Dateiumbenennung via
* {@link ManualFileRenameUseCase}.
* <p>
* Mögliche Ergebnisse:
* <ul>
* <li>{@link ManualFileRenameSuccess} Umbenennung war erfolgreich (ggf. mit Suffix).</li>
* <li>{@link ManualFileRenameNoOpIdenticalTarget} keine Aktion erforderlich, da die
* Zieldatei bereits denselben Inhalt hat.</li>
* <li>{@link ManualFileRenameDocumentNotFound} das Dokument wurde in der Persistenz
* nicht gefunden.</li>
* <li>{@link ManualFileRenameInvalidState} das Dokument befindet sich in einem
* ungültigen Zustand für eine Umbenennung.</li>
* <li>{@link ManualFileRenameSourceFileMissing} die bisherige Zieldatei existiert
* im Zielordner nicht mehr.</li>
* <li>{@link ManualFileRenameFileSystemFailure} ein technischer Dateisystemfehler
* ist aufgetreten.</li>
* <li>{@link ManualFileRenamePersistenceFailure} die Persistenzaktualisierung ist
* fehlgeschlagen (Dateisystem ggf. zurückgerollt).</li>
* </ul>
*/
public sealed interface ManualFileRenameResult
permits ManualFileRenameSuccess,
ManualFileRenameNoOpIdenticalTarget,
ManualFileRenameDocumentNotFound,
ManualFileRenameInvalidState,
ManualFileRenameSourceFileMissing,
ManualFileRenameFileSystemFailure,
ManualFileRenamePersistenceFailure {
}
@@ -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.
* <p>
* 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.
* <p>
* 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");
}
}
@@ -0,0 +1,34 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.util.Objects;
/**
* Ergebnis einer erfolgreich abgeschlossenen manuellen Dateiumbenennung.
* <p>
* 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");
}
}
@@ -0,0 +1,39 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
/**
* Inbound-Port für die manuelle Umbenennung einer bereits erfolgreich verarbeiteten
* Zieldatei.
* <p>
* 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.
* <p>
* 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.
* <p>
* <strong>Konfliktsemantik:</strong> Existiert im Zielordner bereits eine Datei mit dem
* gewünschten Namen, wird anhand des Inhalts-Fingerprints entschieden:
* <ul>
* <li>Gleicher Fingerprint → keine Aktion ({@link ManualFileRenameNoOpIdenticalTarget})</li>
* <li>Verschiedener Fingerprint → automatische Suffix-Vergabe ({@code (1)}, {@code (2)}, …)</li>
* </ul>
*/
public interface ManualFileRenameUseCase {
/**
* Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
* <p>
* 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);
}
@@ -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.
* <p>
* 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");
}
}
@@ -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.
* <p>
* 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");
}
}
@@ -0,0 +1,41 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound-Port für das Umbenennen einer bereits existierenden Datei im Zielordner.
* <p>
* 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.
* <p>
* <strong>Zuständigkeit:</strong> 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)}.
* <p>
* <strong>Architekturgrenze:</strong> 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.
* <p>
* 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);
}
@@ -0,0 +1,23 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Versiegeltes Ergebnis-Interface für eine Umbenennung einer Zieldatei via
* {@link TargetFileRenamePort}.
* <p>
* Mögliche Ergebnisse:
* <ul>
* <li>{@link TargetFileRenameSuccess} die Umbenennung war erfolgreich.</li>
* <li>{@link TargetFileRenameFailureFileNotFound} die ursprüngliche Datei wurde
* im Zielordner nicht gefunden.</li>
* <li>{@link TargetFileRenameFailureTargetExists} der gewünschte neue Dateiname
* existiert bereits und gehört zu einer anderen Datei.</li>
* <li>{@link TargetFileRenameTechnicalFailure} ein technischer Fehler beim
* Dateisystemzugriff ist aufgetreten.</li>
* </ul>
*/
public sealed interface TargetFileRenameResult
permits TargetFileRenameSuccess,
TargetFileRenameFailureFileNotFound,
TargetFileRenameFailureTargetExists,
TargetFileRenameTechnicalFailure {
}
@@ -0,0 +1,10 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Ergebnis einer erfolgreichen Umbenennung einer Zieldatei via {@link TargetFileRenamePort}.
* <p>
* Gibt an, dass die Datei im Zielordner erfolgreich von ihrem alten auf den neuen
* Dateinamen umbenannt wurde.
*/
public record TargetFileRenameSuccess() implements TargetFileRenameResult {
}
@@ -0,0 +1,24 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.Objects;
/**
* Ergebnis einer technisch fehlgeschlagenen Umbenennung einer Zieldatei.
* <p>
* 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");
}
}
@@ -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}.
* <p>
* Führt die manuelle Umbenennung einer Zieldatei als atomare Operation durch:
* Entweder werden Dateisystem und Persistenz beide aktualisiert, oder beide
* bleiben im vorherigen Zustand.
* <p>
* <strong>Ablauf:</strong>
* <ol>
* <li>Dokument-Stammsatz aus dem Repository laden und Zustand prüfen.</li>
* <li>Prüfen, ob der gewünschte Name bereits dem aktuellen entspricht (No-Op).</li>
* <li>Eindeutigen Zieldateinamen über {@link TargetFolderPort} auflösen.</li>
* <li>Zieldatei im Dateisystem umbenennen via {@link TargetFileRenamePort}.</li>
* <li>Dokument-Stammsatz in der Persistenz aktualisieren.</li>
* <li>Bei Persistenzfehler: Best-Effort-Rollback der Dateisystem-Umbenennung.</li>
* </ol>
* <p>
* 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.
* <p>
* 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);
}
}
@@ -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}.
* <p>
* 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<String[]> renameArgs = new ArrayList<>();
List<DocumentRecord> updatedRecords = new ArrayList<>();
TargetFileRenamePort renamePort = (oldName, newName) -> {
renameArgs.add(new String[]{oldName, newName});
return new TargetFileRenameSuccess();
};
UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords));
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePort,
uow,
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameSuccess.class);
ManualFileRenameSuccess success = (ManualFileRenameSuccess) result;
assertThat(success.previousFileName()).isEqualTo(CURRENT_FILE);
assertThat(success.appliedFileName()).isEqualTo(DESIRED_FULL);
assertThat(success.conflictSuffixApplied()).isFalse();
assertThat(renameArgs).hasSize(1);
assertThat(renameArgs.get(0)[0]).isEqualTo(CURRENT_FILE);
assertThat(renameArgs.get(0)[1]).isEqualTo(DESIRED_FULL);
assertThat(updatedRecords).hasSize(1);
assertThat(updatedRecords.get(0).lastTargetFileName()).isEqualTo(DESIRED_FULL);
assertThat(updatedRecords.get(0).updatedAt()).isEqualTo(FIXED_NOW);
}
// -------------------------------------------------------------------------
// Testfall 2: Konflikt mit anderer Datei → Suffix angewendet
// -------------------------------------------------------------------------
@Test
void rename_appliesSuffix_whenConflictWithDifferentFingerprint() {
String suffixedName = DESIRED_BASE + "(1).pdf";
List<DocumentRecord> updatedRecords = new ArrayList<>();
UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords));
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
folderPortReturning(new ResolvedTargetFilename(suffixedName)),
renamePortReturning(new TargetFileRenameSuccess()),
uow,
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameSuccess.class);
ManualFileRenameSuccess success = (ManualFileRenameSuccess) result;
assertThat(success.appliedFileName()).isEqualTo(suffixedName);
assertThat(success.conflictSuffixApplied()).isTrue();
assertThat(updatedRecords).hasSize(1);
assertThat(updatedRecords.get(0).lastTargetFileName()).isEqualTo(suffixedName);
}
// -------------------------------------------------------------------------
// Testfall 3: No-Op gewünschter Name ist identisch mit aktuellem
// -------------------------------------------------------------------------
@Test
void rename_returnsNoOp_whenNewNameEqualsCurrent() {
String currentName = DESIRED_FULL; // Gleicher Name wie gewünscht
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(currentName))),
folderPortReturning(new ResolvedTargetFilename("wird nicht aufgerufen.pdf")),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameNoOpIdenticalTarget.class);
ManualFileRenameNoOpIdenticalTarget noOp = (ManualFileRenameNoOpIdenticalTarget) result;
assertThat(noOp.existingFileName()).isEqualTo(currentName);
}
// -------------------------------------------------------------------------
// Testfall 4: No-Op TargetFolderPort meldet identischen Inhalt
// -------------------------------------------------------------------------
@Test
void rename_returnsNoOp_whenTargetFolderReportsIdenticalContent() {
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
folderPortReturning(new ExistingIdenticalTargetFile(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameNoOpIdenticalTarget.class);
assertThat(((ManualFileRenameNoOpIdenticalTarget) result).existingFileName()).isEqualTo(DESIRED_FULL);
}
// -------------------------------------------------------------------------
// Testfall 5: Dokument nicht gefunden
// -------------------------------------------------------------------------
@Test
void rename_returnsDocumentNotFound_whenRepositoryReturnsDocumentUnknown() {
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentUnknown()),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameDocumentNotFound.class);
}
// -------------------------------------------------------------------------
// Testfall 6: Ungültiger Zustand kein Zieldateiname vorhanden
// -------------------------------------------------------------------------
@Test
void rename_returnsInvalidState_whenDocumentHasNoTargetFilename() {
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecordWithoutTargetFile())),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameInvalidState.class);
}
// -------------------------------------------------------------------------
// Testfall 7: Ungültiger Zustand Status ist nicht SUCCESS
// -------------------------------------------------------------------------
@Test
void rename_returnsInvalidState_whenStatusIsNotSuccess() {
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalFinalFailure(failedRecord())),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameInvalidState.class);
}
// -------------------------------------------------------------------------
// Testfall 8: Bisherige Zieldatei nicht mehr vorhanden
// -------------------------------------------------------------------------
@Test
void rename_returnsSourceFileMissing_whenOldFileGone() {
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameFailureFileNotFound(CURRENT_FILE)),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameSourceFileMissing.class);
assertThat(((ManualFileRenameSourceFileMissing) result).expectedFileName()).isEqualTo(CURRENT_FILE);
}
// -------------------------------------------------------------------------
// Testfall 9: Technischer Dateisystemfehler beim Umbenennen
// -------------------------------------------------------------------------
@Test
void rename_returnsFileSystemFailure_whenRenameHasTechnicalError() {
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameTechnicalFailure("Datei gesperrt")),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameFileSystemFailure.class);
assertThat(((ManualFileRenameFileSystemFailure) result).message()).contains("Datei gesperrt");
}
// -------------------------------------------------------------------------
// Testfall 10: Persistenzfehler → Rollback der Umbenennung
// -------------------------------------------------------------------------
@Test
void rename_rollsBackRename_whenPersistenceFails() {
List<String[]> renameArgs = new ArrayList<>();
TargetFileRenamePort renamePort = (oldName, newName) -> {
renameArgs.add(new String[]{oldName, newName});
return new TargetFileRenameSuccess();
};
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePort,
throwingUnitOfWork(new DocumentPersistenceException("DB nicht erreichbar")),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
// Ergebnis ist PersistenceFailure
assertThat(result).isInstanceOf(ManualFileRenamePersistenceFailure.class);
// Erster Aufruf: Eigentliche Umbenennung
assertThat(renameArgs.get(0)[0]).isEqualTo(CURRENT_FILE);
assertThat(renameArgs.get(0)[1]).isEqualTo(DESIRED_FULL);
// Zweiter Aufruf: Rollback
assertThat(renameArgs).hasSize(2);
assertThat(renameArgs.get(1)[0]).isEqualTo(DESIRED_FULL);
assertThat(renameArgs.get(1)[1]).isEqualTo(CURRENT_FILE);
}
// -------------------------------------------------------------------------
// Testfall 11: Persistenzfehler + Rollback schlägt fehl → PersistenceFailure
// -------------------------------------------------------------------------
@Test
void rename_logsErrorButStillReturnsPersistenceFailure_whenRollbackRenameAlsoFails() {
List<String[]> renameArgs = new ArrayList<>();
TargetFileRenamePort renamePort = (oldName, newName) -> {
renameArgs.add(new String[]{oldName, newName});
// Erster Aufruf: Erfolg; Zweiter Aufruf (Rollback): technischer Fehler
if (renameArgs.size() == 1) {
return new TargetFileRenameSuccess();
} else {
return new TargetFileRenameTechnicalFailure("Rollback fehlgeschlagen");
}
};
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePort,
throwingUnitOfWork(new DocumentPersistenceException("DB-Fehler")),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
// Trotz Rollback-Fehler: Ergebnis bleibt PersistenceFailure
assertThat(result).isInstanceOf(ManualFileRenamePersistenceFailure.class);
// Rollback wurde versucht (2 Aufrufe insgesamt)
assertThat(renameArgs).hasSize(2);
}
// -------------------------------------------------------------------------
// Testfall 12: Technischer Fehler beim Zielordner-Zugriff
// -------------------------------------------------------------------------
@Test
void rename_returnsFileSystemFailure_whenTargetFolderTechnicalFailure() {
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
folderPortReturning(new TargetFolderTechnicalFailure("Laufwerk nicht erreichbar")),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameFileSystemFailure.class);
assertThat(((ManualFileRenameFileSystemFailure) result).message()).contains("Laufwerk nicht erreichbar");
}
// -------------------------------------------------------------------------
// Testfall 13: .pdf-Erweiterung wird automatisch angehängt
// -------------------------------------------------------------------------
@Test
void rename_appendsPdfExtensionAutomatically() {
List<String[]> folderArgs = new ArrayList<>();
TargetFolderPort folderPort = new TargetFolderPort() {
@Override public String getTargetFolderLocator() { return "/zielordner"; }
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) {
folderArgs.add(new String[]{baseName});
return new ResolvedTargetFilename(baseName);
}
@Override public void tryDeleteTargetFile(String name) { }
};
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentTerminalSuccess(successRecord(CURRENT_FILE))),
folderPort,
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
// Eingabe ohne .pdf-Erweiterung
useCase.rename(new ManualFileRenameRequest(FINGERPRINT, "2024-01-01 - Ohne Erweiterung"));
// Genau einmal aufgerufen, mit .pdf-Erweiterung
assertThat(folderArgs).hasSize(1);
assertThat(folderArgs.get(0)[0]).endsWith(".pdf");
assertThat(folderArgs.get(0)[0]).isEqualTo("2024-01-01 - Ohne Erweiterung.pdf");
}
// -------------------------------------------------------------------------
// Testfall: Nicht-processable Dokumentstatus → DocumentNotFound
// -------------------------------------------------------------------------
@Test
void rename_returnsDocumentNotFound_whenDocumentIsKnownProcessable() {
DocumentRecord knownRecord = new DocumentRecord(
FINGERPRINT,
new SourceDocumentLocator("/quelldatei.pdf"),
"quelldatei.pdf",
ProcessingStatus.FAILED_RETRYABLE,
FailureCounters.zero(),
FIXED_NOW,
null,
FIXED_NOW,
FIXED_NOW,
null,
null);
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentKnownProcessable(knownRecord)),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
ManualFileRenameResult result = useCase.rename(new ManualFileRenameRequest(FINGERPRINT, DESIRED_BASE));
assertThat(result).isInstanceOf(ManualFileRenameDocumentNotFound.class);
}
// -------------------------------------------------------------------------
// Testfall: Konstruktor-Null-Guards
// -------------------------------------------------------------------------
@Test
void constructor_rejectsNullRepository() {
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileRenameUseCase(
null,
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger()));
}
@Test
void constructor_rejectsNullTargetFolderPort() {
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentUnknown()),
null,
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger()));
}
@Test
void constructor_rejectsNullTargetFileRenamePort() {
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentUnknown()),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
null,
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger()));
}
@Test
void constructor_rejectsNullUnitOfWorkPort() {
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentUnknown()),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
null,
fixedClock(),
noOpLogger()));
}
@Test
void constructor_rejectsNullClock() {
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentUnknown()),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
null,
noOpLogger()));
}
@Test
void constructor_rejectsNullLogger() {
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentUnknown()),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
null));
}
@Test
void rename_rejectsNullRequest() {
DefaultManualFileRenameUseCase useCase = new DefaultManualFileRenameUseCase(
repositoryReturning(new DocumentUnknown()),
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
renamePortReturning(new TargetFileRenameSuccess()),
alwaysSucceedingUnitOfWork(),
fixedClock(),
noOpLogger());
assertThatNullPointerException().isThrownBy(() -> useCase.rename(null));
}
// -------------------------------------------------------------------------
// Hilfsklassen
// -------------------------------------------------------------------------
/** Führt keine Persistenzoperationen durch. */
private static class NoOpTransactionOperations implements UnitOfWorkPort.TransactionOperations {
@Override public void saveProcessingAttempt(ProcessingAttempt attempt) { }
@Override public void createDocumentRecord(DocumentRecord record) { }
@Override public void updateDocumentRecord(DocumentRecord record) { }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
}
/** Zeichnet updateDocumentRecord-Aufrufe auf. */
private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations {
private final List<DocumentRecord> captured;
RecordCapturingTransactionOperations(List<DocumentRecord> captured) {
this.captured = captured;
}
@Override public void saveProcessingAttempt(ProcessingAttempt attempt) { }
@Override public void createDocumentRecord(DocumentRecord record) { }
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
}
}
@@ -24,6 +24,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
@@ -46,6 +47,7 @@ import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepo
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFileRenameAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
@@ -53,6 +55,9 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfigurat
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
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.ManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
@@ -67,6 +72,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
@@ -74,6 +80,7 @@ import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
@@ -675,6 +682,7 @@ public class BootstrapRunner {
this::launchGuiMiniBatchRun;
de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort =
this::resetDocumentStatusForGui;
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
if (configPathOverride.isEmpty()) {
return new GuiStartupContext(
@@ -690,7 +698,8 @@ public class BootstrapRunner {
correctionExecutionService,
batchRunLauncher,
miniRunLauncher,
resetPort);
resetPort,
manualRenamePort);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -711,7 +720,8 @@ public class BootstrapRunner {
correctionExecutionService,
batchRunLauncher,
miniRunLauncher,
resetPort);
resetPort,
manualRenamePort);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -720,7 +730,7 @@ public class BootstrapRunner {
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetPort);
miniRunLauncher, resetPort, manualRenamePort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -737,7 +747,8 @@ public class BootstrapRunner {
correctionExecutionService,
batchRunLauncher,
miniRunLauncher,
resetPort);
resetPort,
manualRenamePort);
}
}
@@ -954,6 +965,103 @@ public class BootstrapRunner {
}
}
/**
* Erstellt einen vollständig verdrahteten {@link ManualFileRenameUseCase} für den
* gegebenen Startkonfigurations-Stand.
* <p>
* 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.
* <p>
* 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.