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:
@@ -136,9 +136,9 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
|
|||||||
## Aktiver Implementierungsstand
|
## Aktiver Implementierungsstand
|
||||||
V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben.
|
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
|
## Statussemantik
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ Verbindlicher Ablauf:
|
|||||||
6. Erst danach den normalen Lauf fortsetzen
|
6. Erst danach den normalen Lauf fortsetzen
|
||||||
|
|
||||||
## Nicht-Ziele / Verbote
|
## 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 DB-/Historien-Tab in der GUI (erst V2.x+)
|
||||||
- kein Kosten-Tracking (erst V2.x+)
|
- kein Kosten-Tracking (erst V2.x+)
|
||||||
- kein echter Mini-KI-Testaufruf mit fachlicher Antwortauswertung
|
- kein echter Mini-KI-Testaufruf mit fachlicher Antwortauswertung
|
||||||
|
|||||||
@@ -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.
|
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.
|
> 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.
|
> Der headless Batch-Betrieb bleibt über `--headless` vollständig erhalten.
|
||||||
> Details zum Betrieb: [`docs/betrieb.md`](docs/betrieb.md)
|
> Details zum Betrieb: [`docs/betrieb.md`](docs/betrieb.md)
|
||||||
|
|
||||||
@@ -212,7 +213,7 @@ Empfohlene Leserichtung:
|
|||||||
|
|
||||||
## Status des Projekts
|
## 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
|
- Konfiguration und Startvalidierung
|
||||||
- Quellordner-Scan und PDF-Textauslese
|
- Quellordner-Scan und PDF-Textauslese
|
||||||
@@ -221,6 +222,8 @@ Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der a
|
|||||||
- Dateinamensbildung und Zielkopie
|
- Dateinamensbildung und Zielkopie
|
||||||
- Retry-Logik, Logging und betriebliche Robustheit
|
- Retry-Logik, Logging und betriebliche Robustheit
|
||||||
- JavaFX-Desktop-GUI als Standardstart (Konfigurationseditor, Validierung, technische Tests)
|
- 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)
|
- headless Batch-Betrieb über `--headless` (rückwärtskompatibel zu V1.x)
|
||||||
|
|
||||||
## Lizenz / Nutzung
|
## Lizenz / Nutzung
|
||||||
|
|||||||
@@ -39,6 +39,32 @@
|
|||||||
<artifactId>javafx-controls</artifactId>
|
<artifactId>javafx-controls</artifactId>
|
||||||
<classifier>win</classifier>
|
<classifier>win</classifier>
|
||||||
</dependency>
|
</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 -->
|
<!-- Logging -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
+82
-1
@@ -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.GuiBatchRunLauncher;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
|
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.GuiMiniRunLauncher;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
|
||||||
@@ -363,6 +364,12 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
private final GuiResetDocumentStatusPort resetDocumentStatusPort;
|
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
|
* 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
|
* during workspace construction and wired into the shared {@link #tabPane} alongside
|
||||||
@@ -437,13 +444,17 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this.batchRunLauncher = effectiveContext.batchRunLauncher();
|
this.batchRunLauncher = effectiveContext.batchRunLauncher();
|
||||||
this.miniRunLauncher = effectiveContext.miniRunLauncher();
|
this.miniRunLauncher = effectiveContext.miniRunLauncher();
|
||||||
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
|
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
|
||||||
|
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
|
||||||
this.batchRunTab = new GuiBatchRunTab(
|
this.batchRunTab = new GuiBatchRunTab(
|
||||||
() -> this.batchRunLauncher,
|
() -> this.batchRunLauncher,
|
||||||
() -> this.miniRunLauncher,
|
() -> this.miniRunLauncher,
|
||||||
() -> this.resetDocumentStatusPort,
|
() -> this.resetDocumentStatusPort,
|
||||||
this::loadedConfigurationPath,
|
this::loadedConfigurationPath,
|
||||||
this::isSavedConfigurationReady,
|
this::isSavedConfigurationReady,
|
||||||
this::applyBatchRunLockState);
|
this::applyBatchRunLockState,
|
||||||
|
() -> this.manualFileRenamePort,
|
||||||
|
this::editorSourceFolder,
|
||||||
|
this::editorTargetFolder);
|
||||||
|
|
||||||
configureRoot();
|
configureRoot();
|
||||||
configureHeader(effectiveContext.startupNotice());
|
configureHeader(effectiveContext.startupNotice());
|
||||||
@@ -462,6 +473,18 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
return batchRunTab;
|
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
|
* 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
|
* 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();
|
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
|
* Applies the "batch run active" UI lock state to the configuration tab and the
|
||||||
* action bar.
|
* action bar.
|
||||||
@@ -523,6 +580,14 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
handleCloseWhileRunRunning(stage);
|
handleCloseWhileRunRunning(stage);
|
||||||
return;
|
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()) {
|
if (!editorState.isDirty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1136,6 +1201,22 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
|
|
||||||
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
|
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
|
||||||
root.setCenter(tabPane);
|
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() {
|
private void configureActionBar() {
|
||||||
|
|||||||
+66
-9
@@ -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.GuiBatchRunLaunchOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
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.GuiMiniRunLauncher;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
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.GuiConfigurationEditorState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
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.in.ResetDocumentStatusResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
import de.gecheckt.pdf.umbenenner.application.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
|
* {@link CorrectionExecutionService} used to execute corrective actions after a
|
||||||
* technical test run has been confirmed by the user, the {@link GuiBatchRunLauncher} used
|
* 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
|
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
|
||||||
* mini-runs for selected documents, and the {@link GuiResetDocumentStatusPort} used to
|
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
|
||||||
* reset the persistence status of selected documents.
|
* reset the persistence status of selected documents, and the
|
||||||
|
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI.
|
||||||
* <p>
|
* <p>
|
||||||
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
|
* 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.
|
* know about provider-specific HTTP details or adapter wiring.
|
||||||
@@ -54,7 +58,8 @@ public record GuiStartupContext(
|
|||||||
CorrectionExecutionService correctionExecutionService,
|
CorrectionExecutionService correctionExecutionService,
|
||||||
GuiBatchRunLauncher batchRunLauncher,
|
GuiBatchRunLauncher batchRunLauncher,
|
||||||
GuiMiniRunLauncher miniRunLauncher,
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
GuiResetDocumentStatusPort resetDocumentStatusPort) {
|
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
||||||
|
GuiManualFileRenamePort manualFileRenamePort) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a fully wired startup context.
|
* Creates a fully wired startup context.
|
||||||
@@ -74,6 +79,8 @@ public record GuiStartupContext(
|
|||||||
* documents; must not be {@code null}
|
* documents; must not be {@code null}
|
||||||
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
|
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
|
||||||
* documents; must not be {@code null}
|
* 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 {
|
public GuiStartupContext {
|
||||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||||
@@ -100,11 +107,53 @@ public record GuiStartupContext(
|
|||||||
"miniRunLauncher must not be null");
|
"miniRunLauncher must not be null");
|
||||||
resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
|
resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
|
||||||
"resetDocumentStatusPort must not be null");
|
"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
|
* Backward-compatible constructor that fills the manual-rename port with a no-op
|
||||||
* with no-op implementations.
|
* 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 initialState initial editor state; must not be {@code null}
|
||||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
@@ -133,12 +182,12 @@ public record GuiStartupContext(
|
|||||||
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
rejectingMiniRunLauncher(), rejectingResetPort());
|
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backward-compatible constructor that fills the processing-run launcher, mini-run
|
* 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>
|
* <p>
|
||||||
* Preserves existing callers that were written before the processing-run tab was added.
|
* 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,
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService,
|
technicalTestOrchestrator, correctionExecutionService,
|
||||||
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort());
|
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||||
|
rejectingManualFileRenamePort());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
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.
|
* Creates a blank startup context with no-op implementations for all ports and services.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -262,6 +318,7 @@ public record GuiStartupContext(
|
|||||||
noOpCorrectionService,
|
noOpCorrectionService,
|
||||||
noOpBatchRunLauncher,
|
noOpBatchRunLauncher,
|
||||||
rejectingMiniRunLauncher(),
|
rejectingMiniRunLauncher(),
|
||||||
rejectingResetPort());
|
rejectingResetPort(),
|
||||||
|
rejectingManualFileRenamePort());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+432
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-3
@@ -30,6 +30,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
|||||||
* @param status the aggregated completion status; never {@code null}
|
* @param status the aggregated completion status; never {@code null}
|
||||||
* @param finalFileName the final target filename when the row represents a successful
|
* @param finalFileName the final target filename when the row represents a successful
|
||||||
* rename; empty otherwise
|
* 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
|
* @param resolvedDate the resolved document date when the row represents a successful
|
||||||
* rename; empty otherwise
|
* rename; empty otherwise
|
||||||
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
|
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
|
||||||
@@ -47,6 +50,7 @@ public record GuiBatchRunResultRow(
|
|||||||
DocumentFingerprint fingerprint,
|
DocumentFingerprint fingerprint,
|
||||||
DocumentCompletionStatus status,
|
DocumentCompletionStatus status,
|
||||||
Optional<String> finalFileName,
|
Optional<String> finalFileName,
|
||||||
|
Optional<String> correctedFileName,
|
||||||
Optional<LocalDate> resolvedDate,
|
Optional<LocalDate> resolvedDate,
|
||||||
Optional<String> aiReasoning,
|
Optional<String> aiReasoning,
|
||||||
Optional<String> aiFailureMessage,
|
Optional<String> aiFailureMessage,
|
||||||
@@ -81,6 +85,7 @@ public record GuiBatchRunResultRow(
|
|||||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
Objects.requireNonNull(status, "status must not be null");
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
|
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
|
||||||
|
correctedFileName = correctedFileName == null ? Optional.empty() : correctedFileName;
|
||||||
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
|
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
|
||||||
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
|
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
|
||||||
aiFailureMessage = aiFailureMessage == null ? Optional.empty() : aiFailureMessage;
|
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 originalFileName the source filename; never {@code null} or blank
|
||||||
* @param fingerprint the content-based document identity; never {@code null}
|
* @param fingerprint the content-based document identity; never {@code null}
|
||||||
@@ -115,8 +121,40 @@ public record GuiBatchRunResultRow(
|
|||||||
Optional<String> aiReasoning,
|
Optional<String> aiReasoning,
|
||||||
Optional<String> aiFailureMessage,
|
Optional<String> aiFailureMessage,
|
||||||
Duration processingDuration) {
|
Duration processingDuration) {
|
||||||
this(originalFileName, fingerprint, status, finalFileName, resolvedDate, aiReasoning,
|
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
|
||||||
aiFailureMessage, processingDuration, false);
|
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";
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+483
-166
File diff suppressed because it is too large
Load Diff
+46
@@ -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);
|
||||||
|
}
|
||||||
+390
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+373
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -6,6 +6,9 @@
|
|||||||
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter}
|
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter}
|
||||||
* — Filesystem-based implementation of
|
* — Filesystem-based implementation of
|
||||||
* {@link de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort}.</li>
|
* {@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>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Duplicate resolution:</strong> Given a base name such as
|
* <strong>Duplicate resolution:</strong> Given a base name such as
|
||||||
|
|||||||
+143
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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 {
|
||||||
|
}
|
||||||
+29
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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);
|
||||||
|
}
|
||||||
+26
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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);
|
||||||
|
}
|
||||||
+23
@@ -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 {
|
||||||
|
}
|
||||||
+10
@@ -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 {
|
||||||
|
}
|
||||||
+24
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+237
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+640
@@ -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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
-4
@@ -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.GuiStartupContext;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
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.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.GuiConfigurationEditorState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
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.SqliteSchemaInitializationAdapter;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter;
|
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.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.adapter.out.targetfolder.FilesystemTargetFolderAdapter;
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
|
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
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.config.startup.StartConfiguration;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
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.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.in.ResetDocumentStatusResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
|
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
|
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.PromptPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
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.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.TargetFolderPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
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.AiResponseValidator;
|
||||||
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
|
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.usecase.DefaultResetDocumentStatusUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
|
||||||
@@ -675,6 +682,7 @@ public class BootstrapRunner {
|
|||||||
this::launchGuiMiniBatchRun;
|
this::launchGuiMiniBatchRun;
|
||||||
de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort =
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort =
|
||||||
this::resetDocumentStatusForGui;
|
this::resetDocumentStatusForGui;
|
||||||
|
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
|
||||||
|
|
||||||
if (configPathOverride.isEmpty()) {
|
if (configPathOverride.isEmpty()) {
|
||||||
return new GuiStartupContext(
|
return new GuiStartupContext(
|
||||||
@@ -690,7 +698,8 @@ public class BootstrapRunner {
|
|||||||
correctionExecutionService,
|
correctionExecutionService,
|
||||||
batchRunLauncher,
|
batchRunLauncher,
|
||||||
miniRunLauncher,
|
miniRunLauncher,
|
||||||
resetPort);
|
resetPort,
|
||||||
|
manualRenamePort);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path configPath = Paths.get(configPathOverride.get());
|
Path configPath = Paths.get(configPathOverride.get());
|
||||||
@@ -711,7 +720,8 @@ public class BootstrapRunner {
|
|||||||
correctionExecutionService,
|
correctionExecutionService,
|
||||||
batchRunLauncher,
|
batchRunLauncher,
|
||||||
miniRunLauncher,
|
miniRunLauncher,
|
||||||
resetPort);
|
resetPort,
|
||||||
|
manualRenamePort);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
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,
|
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
|
||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetPort);
|
miniRunLauncher, resetPort, manualRenamePort);
|
||||||
} catch (GuiConfigurationLoadException e) {
|
} catch (GuiConfigurationLoadException e) {
|
||||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||||
e.getMessage(), e);
|
e.getMessage(), e);
|
||||||
@@ -737,7 +747,8 @@ public class BootstrapRunner {
|
|||||||
correctionExecutionService,
|
correctionExecutionService,
|
||||||
batchRunLauncher,
|
batchRunLauncher,
|
||||||
miniRunLauncher,
|
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
|
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
|
||||||
* recorded as a failure with the given error message.
|
* recorded as a failure with the given error message.
|
||||||
|
|||||||
Reference in New Issue
Block a user