diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 728e7b0..1667ade 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -57,6 +57,7 @@ import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.control.Alert; import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; import javafx.scene.control.ButtonType; import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; @@ -422,6 +423,33 @@ public final class GuiConfigurationEditorWorkspace { */ private final GuiPromptEditorPortFactory promptEditorPortFactory; + /** + * Bridge zur DB-Anlage- und Wechsellogik. Wird vom Menüpunkt + * „Datenbank → Neue Datenbank anlegen…" ausgelöst. + */ + private final GuiCreateNewDatabasePort createNewDatabasePort; + + /** + * Aktiver DB-Busy-Zustand während einer laufenden Datenbank-Anlage. Solange + * dieser Zustand aktiv ist, sind alle DB-lesenden und DB-schreibenden Aktionen + * der GUI gesperrt (vgl. {@link #applyDbBusyLock()}). + *
+ * Als JavaFX-Property realisiert, damit die Menüleiste den Zustand direkt + * über {@code disableProperty().bind(...)} auswerten kann. + */ + private final javafx.beans.property.SimpleBooleanProperty dbBusyForDatabaseCreation = + new javafx.beans.property.SimpleBooleanProperty(false); + + /** + * Hintergrund-Worker-Thread für die DB-Anlage; einzel-threadig, damit nicht + * mehrere DB-Anlagen gleichzeitig laufen können. + */ + private final ExecutorService createNewDatabaseExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "gui-create-new-database"); + thread.setDaemon(true); + return thread; + }); + /** * 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 @@ -513,6 +541,7 @@ public final class GuiConfigurationEditorWorkspace { this.manualFileCopyPort = effectiveContext.manualFileCopyPort(); this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort(); this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory(); + this.createNewDatabasePort = effectiveContext.createNewDatabasePort(); this.batchRunTab = new GuiBatchRunTab( () -> this.batchRunLauncher, () -> this.miniRunLauncher, @@ -1018,6 +1047,218 @@ public final class GuiConfigurationEditorWorkspace { checkExistsAndSave(targetPath, () -> { }); } + /** + * Liefert {@code true}, wenn aktuell gerade eine Datenbank-Anlage läuft und der + * Menüpunkt „Datenbank → Neue Datenbank anlegen…" daher gesperrt ist. + * + * @return aktueller DB-Busy-Zustand + */ + public boolean isDbBusyForDatabaseCreation() { + return dbBusyForDatabaseCreation.get(); + } + + /** + * Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty} für den + * DB-Busy-Zustand. Wird von der Menüleiste genutzt, um den Menüpunkt + * „Neue Datenbank anlegen…" während einer laufenden Anlage automatisch zu + * deaktivieren. + * + * @return read-only Property; nie {@code null} + */ + public javafx.beans.property.BooleanProperty dbBusyForDatabaseCreationProperty() { + return dbBusyForDatabaseCreation; + } + + /** + * Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty}, die den + * Lauf-aktiv-Zustand des Verarbeitungslauf-Tabs spiegelt. Wird von der + * Menüleiste genutzt, um den Menüpunkt „Neue Datenbank anlegen…" während + * eines laufenden Verarbeitungslaufs zu deaktivieren. + * + * @return read-only Property; nie {@code null} + */ + public javafx.beans.property.ReadOnlyBooleanProperty batchRunRunningProperty() { + return batchRunTab.runningProperty(); + } + + /** + * Behandelt die Aktion „Datenbank → Neue Datenbank anlegen…". + *
+ * Öffnet einen FileChooser (Filter {@code *.sqlite}), prüft den Zielpfad + * gegen die aktive Datenbank, holt ggf. eine Überschreib-Bestätigung ein und + * delegiert die eigentliche Anlage an + * {@link GuiCreateNewDatabasePort#createNewDatabase(Path)} auf einem + * Hintergrund-Worker-Thread. + *
+ * Während der Ausführung ist die GUI in einem DB-Busy-Zustand: alle + * DB-lesenden und DB-schreibenden Aktionen sind deaktiviert. Der Zustand + * wird nach Erfolg oder Fehler zuverlässig zurückgesetzt. + *
+ * Muss auf dem JavaFX Application Thread aufgerufen werden.
+ */
+ public void requestCreateNewDatabase() {
+ if (dbBusyForDatabaseCreation.get()) {
+ LOG.debug("GUI-Editor: Anlage einer neuen Datenbank ist bereits in Arbeit – Klick ignoriert.");
+ return;
+ }
+ if (batchRunTab != null && batchRunTab.isRunning()) {
+ showError("Während eines Verarbeitungslaufs kann keine neue Datenbank angelegt werden.");
+ return;
+ }
+
+ Window owner = root.getScene() == null ? null : root.getScene().getWindow();
+ FileChooser fileChooser = saveFileChooserFactory.get();
+ fileChooser.setTitle("Neue Datenbank anlegen");
+ fileChooser.getExtensionFilters().add(
+ new FileChooser.ExtensionFilter("SQLite-Datenbanken", "*.sqlite"));
+ fileChooser.setInitialFileName("pdf-umbenenner.sqlite");
+
+ // Vorschlagsverzeichnis: SQLite-Pfad aus der aktuellen Konfiguration, sofern gesetzt
+ String currentSqlite = editorState.values().sqliteFile();
+ if (currentSqlite != null && !currentSqlite.isBlank()) {
+ try {
+ Path proposedDir = Path.of(currentSqlite).toAbsolutePath().getParent();
+ if (proposedDir != null && proposedDir.toFile().isDirectory()) {
+ fileChooser.setInitialDirectory(proposedDir.toFile());
+ }
+ } catch (Exception ignore) {
+ // bei ungültigem Pfad: kein Vorschlagsverzeichnis
+ }
+ }
+
+ File selectedFile;
+ try {
+ selectedFile = saveDialogFunction.apply(fileChooser, owner);
+ } catch (UnsupportedOperationException e) {
+ LOG.debug("GUI-Editor: Datenbank-Speichern-Dialog nicht verfügbar (headless).");
+ return;
+ }
+ if (selectedFile == null) {
+ return;
+ }
+ Path requestedTarget = selectedFile.toPath().toAbsolutePath().normalize();
+
+ // Bestätigungsdialog wenn Datei bereits existiert (egal ob fremd oder aktive DB —
+ // die endgültige Sicherheitsprüfung gegen die aktive DB übernimmt der Use-Case).
+ if (java.nio.file.Files.exists(requestedTarget)) {
+ ButtonType ueberschreiben = new ButtonType("Überschreiben", ButtonBar.ButtonData.OK_DONE);
+ ButtonType abbrechen = new ButtonType("Abbrechen", ButtonBar.ButtonData.CANCEL_CLOSE);
+ Optional
+ * Während der Sperre sind die DB-lesenden und DB-schreibenden Aktionen des
+ * Verlauf-Tabs deaktiviert. Andere DB-Operationen laufen pro Aufruf frisch in
+ * Bootstrap und greifen automatisch den DB-Override des
+ * {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort} ab.
+ */
+ private void applyDbBusyLock() {
+ if (historyTab != null) {
+ historyTab.setDbBusy(dbBusyForDatabaseCreation.get());
+ }
+ }
+
/**
* Checks on a background worker thread whether the target path already exists and, if so,
* asks the user to confirm overwriting on the FX Application Thread before writing.
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCreateNewDatabasePort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCreateNewDatabasePort.java
new file mode 100644
index 0000000..878e6a8
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCreateNewDatabasePort.java
@@ -0,0 +1,41 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
+
+import java.nio.file.Path;
+
+import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase.CreateNewDatabaseResult;
+
+/**
+ * GUI-internes Bridge-Interface zwischen dem Workspace und dem
+ * {@link de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase}.
+ *
+ * Dieses Interface ist kein hexagonaler Outbound-Port der Application-Schicht.
+ * Es ist eine modul-interne Brücke, über die Bootstrap die DB-Anlage- und Wechsellogik
+ * für die GUI bereitstellt, ohne dass der GUI-Adapter direkt auf den Use-Case oder die
+ * darunterliegenden Outbound-Ports zugreift.
+ *
+ * Threading: Implementierungen dürfen blockierende Operationen
+ * ausführen (Flyway-Migration, Verbindungstest, atomares Verschieben einer Datei).
+ * Sie müssen daher von einem Hintergrund-Worker-Thread aufgerufen werden. Der Aufruf
+ * blockiert, bis das Ergebnis vollständig vorliegt.
+ */
+@FunctionalInterface
+public interface GuiCreateNewDatabasePort {
+
+ /**
+ * Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und
+ * stellt die aktive Datenbankreferenz auf diese Datei um.
+ *
+ * @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei,
+ * oder {@code null}, wenn (noch) keine Konfiguration
+ * geladen ist. Die Bootstrap-Implementierung leitet
+ * daraus den Pfad der aktuell aktiven SQLite-Datei ab,
+ * sofern noch kein Override vom
+ * {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}
+ * gesetzt ist.
+ * @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht
+ * {@code null} sein
+ * @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler;
+ * nie {@code null}
+ */
+ CreateNewDatabaseResult createNewDatabase(Path configFilePath, Path targetFile);
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
index 85c6dfa..6f0f08f 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
@@ -77,7 +77,8 @@ public record GuiStartupContext(
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
- GuiPromptEditorPortFactory promptEditorPortFactory) {
+ GuiPromptEditorPortFactory promptEditorPortFactory,
+ GuiCreateNewDatabasePort createNewDatabasePort) {
/**
* Creates a fully wired startup context.
@@ -155,6 +156,8 @@ public record GuiStartupContext(
"deleteDocumentHistoryPort must not be null");
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
"promptEditorPortFactory must not be null");
+ createNewDatabasePort = Objects.requireNonNull(createNewDatabasePort,
+ "createNewDatabasePort must not be null");
}
/**
@@ -198,7 +201,8 @@ public record GuiStartupContext(
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
- noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
+ noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
+ rejectingCreateNewDatabasePort());
}
/**
@@ -236,7 +240,8 @@ public record GuiStartupContext(
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
- noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
+ noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
+ rejectingCreateNewDatabasePort());
}
/**
@@ -274,7 +279,8 @@ public record GuiStartupContext(
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
- noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
+ noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
+ rejectingCreateNewDatabasePort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -396,7 +402,25 @@ public record GuiStartupContext(
noOpHistoryDetailsPort(),
noOpHistoryResetPort(),
noOpDeleteHistoryPort(),
- noOpPromptEditorPortFactory());
+ noOpPromptEditorPortFactory(),
+ rejectingCreateNewDatabasePort());
+ }
+
+ /**
+ * Liefert einen ablehnenden {@link GuiCreateNewDatabasePort}, der jede Anlage
+ * sofort als Fehler zurückgibt. Wird verwendet, wenn kein Bootstrap-seitig
+ * verdrahteter Port vorliegt (z. B. in Tests oder vor dem Laden einer
+ * Konfiguration).
+ *
+ * @return ein ablehnender Port; nie {@code null}
+ */
+ private static GuiCreateNewDatabasePort rejectingCreateNewDatabasePort() {
+ return (configFilePath, targetFile) -> new de.gecheckt.pdf.umbenenner.application.port.in
+ .CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed(
+ de.gecheckt.pdf.umbenenner.application.port.in
+ .CreateNewDatabaseUseCase.CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
+ "Kein DB-Anlage-Port in diesem Startkontext verfügbar.",
+ null);
}
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java
index d0b79ac..effa84b 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java
@@ -7,6 +7,9 @@ import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.EventHandler;
import javafx.scene.Scene;
+import javafx.scene.control.Menu;
+import javafx.scene.control.MenuBar;
+import javafx.scene.control.MenuItem;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
@@ -74,8 +77,12 @@ public class PdfUmbenennerGuiApplication extends Application {
GuiStatusBar statusBar = new GuiStatusBar(startupContext.applicationVersion());
workspace.statusBarStateListener = statusBar::applyEditorState;
+ // Menüleiste mit Datenbank-Menü („Neue Datenbank anlegen…")
+ MenuBar menuBar = buildMenuBar(workspace);
+
// Statuszeile unterhalb des Workspace-Inhalts einbetten
BorderPane outerLayout = new BorderPane();
+ outerLayout.setTop(menuBar);
outerLayout.setCenter(workspace.root());
outerLayout.setBottom(statusBar.root());
@@ -115,6 +122,31 @@ public class PdfUmbenennerGuiApplication extends Application {
}
}
+ /**
+ * Baut die Menüleiste für das Hauptfenster auf.
+ *
+ * Aktuell enthält sie genau einen Eintrag: das Menü „Datenbank" mit der Aktion
+ * „Neue Datenbank anlegen…". Diese delegiert an
+ * {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}.
+ *
+ * Der Menüpunkt ist deaktiviert, solange ein Verarbeitungslauf aktiv ist oder
+ * bereits eine DB-Anlage läuft. Die Reaktivierung erfolgt automatisch, sobald
+ * der Workspace die DB-Busy-Sperre wieder aufhebt.
+ *
+ * @param workspace der Workspace, an den die Aktionen delegieren; nie {@code null}
+ * @return die fertig konfigurierte Menüleiste
+ */
+ private MenuBar buildMenuBar(GuiConfigurationEditorWorkspace workspace) {
+ Menu databaseMenu = new Menu("Datenbank");
+ MenuItem createNewItem = new MenuItem("Neue Datenbank anlegen…");
+ createNewItem.setOnAction(event -> workspace.requestCreateNewDatabase());
+ // Sperre während eines aktiven Verarbeitungslaufs oder einer laufenden DB-Anlage
+ createNewItem.disableProperty().bind(workspace.batchRunRunningProperty()
+ .or(workspace.dbBusyForDatabaseCreationProperty()));
+ databaseMenu.getItems().add(createNewItem);
+ return new MenuBar(databaseMenu);
+ }
+
/**
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
* System-Tray minimiert statt es zu schließen.
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java
index b741a9c..0c7f03a 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java
@@ -150,6 +150,15 @@ public final class GuiHistoryTab {
*/
private final PauseTransition searchDebounce = new PauseTransition(Duration.millis(300));
+ /**
+ * Sperre für DB-lesende und DB-schreibende Aktionen während einer
+ * laufenden Datenbank-Anlage (vgl. „Neue Datenbank anlegen"). Wird auf {@code true}
+ * gesetzt, solange die Anlage einer neuen SQLite-Datenbank läuft, und nach Erfolg
+ * oder Fehler zuverlässig zurückgesetzt. Während dieser Zeit sind Suche, Filter,
+ * Aktualisieren, Status-Reset und Löschen deaktiviert.
+ */
+ private boolean dbBusy = false;
+
/**
* Erzeugt den Historien-Tab.
*
@@ -217,6 +226,49 @@ public final class GuiHistoryTab {
}
}
+ /**
+ * Schaltet die DB-Busy-Sperre des Verlauf-Tabs an oder aus.
+ *
+ * Während der Sperre sind Suche, Statusfilter, Aktualisieren, Status-Reset und
+ * Eintrag-Löschen deaktiviert. Wird typischerweise vom Workspace aufgerufen,
+ * solange eine neue SQLite-Datenbank angelegt und aktiviert wird.
+ *
+ * Muss auf dem JavaFX Application Thread aufgerufen werden.
+ *
+ * @param busy {@code true} aktiviert die Sperre, {@code false} hebt sie wieder auf
+ */
+ public void setDbBusy(boolean busy) {
+ this.dbBusy = busy;
+ searchField.setDisable(busy);
+ statusFilterBox.setDisable(busy);
+ refreshButton.setDisable(busy);
+ if (busy) {
+ resetButton.setDisable(true);
+ deleteButton.setDisable(true);
+ } else if (!overviewTable.getSelectionModel().getSelectedItems().isEmpty()
+ && !runningCheck.getAsBoolean()) {
+ resetButton.setDisable(false);
+ deleteButton.setDisable(false);
+ }
+ }
+
+ /**
+ * Lädt die Übersicht erneut, sofern keine DB-Busy-Sperre aktiv ist.
+ *
+ * Wird vom Workspace nach erfolgreichem Datenbank-Wechsel aufgerufen, damit der
+ * Detailbereich und die Liste die neue (leere) Datenbank wiedergeben.
+ *
+ * Muss auf dem JavaFX Application Thread aufgerufen werden.
+ */
+ public void reloadAfterDatabaseSwitch() {
+ // Selektion aufheben, damit der Detailbereich nicht mit Stammdaten
+ // aus der vorherigen Datenbank weiterzeigt.
+ overviewTable.getSelectionModel().clearSelection();
+ overviewItems.clear();
+ clearDetailPane();
+ loadOverview();
+ }
+
// =========================================================================
// UI-Aufbau
// =========================================================================
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapter.java
new file mode 100644
index 0000000..e3f5ef5
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapter.java
@@ -0,0 +1,199 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import javax.sql.DataSource;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.flywaydb.core.Flyway;
+import org.sqlite.SQLiteConfig;
+import org.sqlite.SQLiteDataSource;
+
+import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
+
+/**
+ * SQLite-Implementierung des {@link DatabaseCreationPort}.
+ *
+ * Erzeugt eine neue, leere SQLite-Datenbank gegen einen vom Aufrufer übergebenen
+ * temporären Zielpfad und führt eine vollständige Flyway-Migration auf den neuesten
+ * Schema-Stand aus. Anschließend wird ein Verbindungstest durchgeführt, der drei
+ * Aspekte verifiziert:
+ *
+ * Im Fehlerfall wird die temporäre Datei zuverlässig wieder entfernt; aufrufende
+ * Komponenten erhalten ein klassifiziertes
+ * {@link DatabaseCreationPort.DatabaseCreationResult.Failure}-Ergebnis.
+ *
+ * JDBC, SQLite-Konfiguration und Flyway-spezifische Typen verbleiben vollständig in
+ * dieser Klasse. Nach außen wird ausschließlich der versiegelte Port-Ergebnistyp
+ * herausgereicht.
+ */
+public class SqliteDatabaseCreationAdapter implements DatabaseCreationPort {
+
+ private static final Logger LOG = LogManager.getLogger(SqliteDatabaseCreationAdapter.class);
+ private static final String FLYWAY_HISTORY_TABLE = "flyway_schema_history";
+
+ /**
+ * Standardkonstruktor.
+ */
+ public SqliteDatabaseCreationAdapter() {
+ // keine Felder, kein Zustand
+ }
+
+ /**
+ * Legt eine neue, leere SQLite-Datenbank an, migriert sie auf den neuesten Stand
+ * und führt einen Verbindungstest durch. Bei Fehlern wird die Temp-Datei entfernt.
+ *
+ * @param tempFile Pfad der zu erzeugenden temporären SQLite-Datei; darf nicht
+ * {@code null} sein und sollte vor dem Aufruf nicht existieren
+ * @return {@link DatabaseCreationResult.Success} bei Erfolg oder
+ * {@link DatabaseCreationResult.Failure} mit klassifizierter Phase
+ */
+ @Override
+ public DatabaseCreationResult createAndInitialize(Path tempFile) {
+ if (tempFile == null) {
+ throw new NullPointerException("tempFile darf nicht null sein");
+ }
+ Path absoluteTemp = tempFile.toAbsolutePath().normalize();
+ LOG.info("Lege neue temporäre SQLite-Datenbank an: {}", absoluteTemp);
+
+ // Verhindern, dass eine versehentlich vorhandene Temp-Datei mitmigiert wird
+ try {
+ if (Files.exists(absoluteTemp)) {
+ Files.delete(absoluteTemp);
+ }
+ } catch (IOException e) {
+ LOG.error("Vorhandene temporäre Datei konnte nicht entfernt werden: {}",
+ absoluteTemp, e);
+ return new DatabaseCreationResult.Failure(
+ DatabaseCreationResult.Phase.FILE_CREATION,
+ "Vorhandene temporäre Datei konnte nicht entfernt werden: " + e.getMessage(),
+ e);
+ }
+
+ String jdbcUrl = buildJdbcUrl(absoluteTemp);
+ DataSource dataSource = createDataSource(jdbcUrl);
+
+ // Schema-Migration auf neuesten Stand
+ try {
+ Flyway flyway = Flyway.configure()
+ .dataSource(dataSource)
+ .locations("classpath:db/migration")
+ .connectRetries(0)
+ .load();
+ flyway.migrate();
+ LOG.info("Flyway-Migration auf neuesten Stand abgeschlossen für: {}", absoluteTemp);
+ } catch (RuntimeException e) {
+ LOG.error("Flyway-Migration fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
+ cleanup(absoluteTemp);
+ return new DatabaseCreationResult.Failure(
+ DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
+ "Schema-Migration fehlgeschlagen: " + e.getMessage(),
+ e);
+ }
+
+ // Verbindungstest gegen die migrierte Temp-Datei
+ try {
+ verifyConnection(dataSource);
+ LOG.info("Verbindungstest gegen neue SQLite-Datenbank erfolgreich: {}", absoluteTemp);
+ } catch (SQLException | IllegalStateException e) {
+ LOG.error("Verbindungstest fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
+ cleanup(absoluteTemp);
+ return new DatabaseCreationResult.Failure(
+ DatabaseCreationResult.Phase.CONNECTION_TEST,
+ "Verbindungstest fehlgeschlagen: " + e.getMessage(),
+ e);
+ }
+
+ return new DatabaseCreationResult.Success(absoluteTemp);
+ }
+
+ /**
+ * Verifiziert die migrierte Datenbank durch drei aufeinander aufbauende Prüfungen.
+ *
+ * @param dataSource die DataSource gegen die Temp-Datei
+ * @throws SQLException bei JDBC-Fehlern
+ * @throws IllegalStateException wenn eine fachliche Erwartung (z. B. Flyway-History
+ * vorhanden, mind. ein erfolgreicher Eintrag) verletzt ist
+ */
+ private void verifyConnection(DataSource dataSource) throws SQLException {
+ try (Connection conn = dataSource.getConnection()) {
+ try (Statement stmt = conn.createStatement()) {
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='"
+ + FLYWAY_HISTORY_TABLE + "'")) {
+ if (!rs.next() || rs.getInt(1) != 1) {
+ throw new IllegalStateException(
+ "Flyway-History-Tabelle fehlt nach der Migration.");
+ }
+ }
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT count(*) FROM " + FLYWAY_HISTORY_TABLE + " WHERE success = 1")) {
+ if (!rs.next() || rs.getInt(1) < 1) {
+ throw new IllegalStateException(
+ "Flyway-History enthält keinen erfolgreichen Migrationseintrag.");
+ }
+ }
+ // einfache Leseabfrage gegen Schema-Metadaten
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT name FROM sqlite_master WHERE type='table'")) {
+ int tableCount = 0;
+ while (rs.next()) {
+ tableCount++;
+ }
+ if (tableCount < 1) {
+ throw new IllegalStateException(
+ "Schema-Metadatenabfrage lieferte keine Tabellen.");
+ }
+ }
+ }
+ }
+ }
+
+ private void cleanup(Path tempFile) {
+ try {
+ Files.deleteIfExists(tempFile);
+ } catch (IOException e) {
+ LOG.warn("Temporäre SQLite-Datei konnte nach Fehler nicht entfernt werden: {} – {}",
+ tempFile, e.getMessage());
+ }
+ }
+
+ /**
+ * Baut die JDBC-URL für eine SQLite-Datei nach dem im Projekt etablierten Schema.
+ *
+ * @param dbFile absoluter Pfad der SQLite-Datei; darf nicht {@code null} sein
+ * @return die JDBC-URL in der Form {@code jdbc:sqlite:/pfad/zur/datei.db}
+ */
+ private static String buildJdbcUrl(Path dbFile) {
+ return "jdbc:sqlite:" + dbFile.toAbsolutePath().toString().replace('\\', '/');
+ }
+
+ /**
+ * Erstellt eine SQLite-DataSource mit aktivierten Fremdschlüsseln.
+ *
+ * @param jdbcUrl die JDBC-URL der SQLite-Datei
+ * @return eine konfigurierte {@link DataSource}; nie {@code null}
+ */
+ private static DataSource createDataSource(String jdbcUrl) {
+ SQLiteConfig config = new SQLiteConfig();
+ config.enforceForeignKeys(true);
+ SQLiteDataSource ds = new SQLiteDataSource(config);
+ ds.setUrl(jdbcUrl);
+ return ds;
+ }
+}
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapterTest.java
new file mode 100644
index 0000000..c4f6c2e
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapterTest.java
@@ -0,0 +1,97 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort.DatabaseCreationResult;
+
+/**
+ * Tests für {@link SqliteDatabaseCreationAdapter}.
+ *
+ * Prüft, dass eine neue, leere SQLite-Datei am übergebenen Temp-Pfad angelegt und
+ * vollständig per Flyway migriert wird, dass der Verbindungstest die Flyway-History
+ * verifiziert und dass Fehler im Verlauf zur Bereinigung der Temp-Datei führen.
+ */
+class SqliteDatabaseCreationAdapterTest {
+
+ @Test
+ void createAndInitialize_shouldRejectNullPath() {
+ SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
+ assertThatThrownBy(() -> adapter.createAndInitialize(null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void createAndInitialize_shouldCreateAndMigrateNewSqliteFile(@TempDir Path tempDir) throws Exception {
+ Path tempFile = tempDir.resolve("new-db.sqlite.tmp");
+ SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
+
+ DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
+
+ assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
+ assertThat(Files.exists(tempFile)).isTrue();
+ assertThat(Files.size(tempFile)).isGreaterThan(0);
+
+ // Schema verifizieren: Flyway-History und fachliche Tabellen müssen existieren
+ String jdbcUrl = "jdbc:sqlite:" + tempFile.toAbsolutePath().toString().replace('\\', '/');
+ try (Connection conn = DriverManager.getConnection(jdbcUrl);
+ Statement stmt = conn.createStatement()) {
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT count(*) FROM sqlite_master WHERE type='table' "
+ + "AND name IN ('flyway_schema_history','document_record','processing_attempt')")) {
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getInt(1)).isEqualTo(3);
+ }
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT count(*) FROM flyway_schema_history WHERE success = 1")) {
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getInt(1)).isGreaterThanOrEqualTo(1);
+ }
+ }
+ }
+
+ @Test
+ void createAndInitialize_shouldOverwriteExistingTempFileBeforeMigration(@TempDir Path tempDir) throws Exception {
+ Path tempFile = tempDir.resolve("existing.tmp");
+ Files.writeString(tempFile, "rest-zustand");
+
+ SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
+ DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
+
+ assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
+ // Die Datei wurde durch eine leere SQLite-Datei ersetzt — der ursprüngliche Inhalt darf nicht mehr
+ // sichtbar sein.
+ assertThat(Files.size(tempFile)).isGreaterThan(0);
+ assertThat(Files.readString(tempFile, java.nio.charset.StandardCharsets.ISO_8859_1))
+ .doesNotContain("rest-zustand");
+ }
+
+ @Test
+ void createAndInitialize_shouldFailAndCleanup_whenParentDirectoryDoesNotExist(@TempDir Path tempDir)
+ throws SQLException {
+ Path missingParent = tempDir.resolve("does-not-exist").resolve("child.tmp");
+
+ SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
+ DatabaseCreationResult result = adapter.createAndInitialize(missingParent);
+
+ assertThat(result).isInstanceOf(DatabaseCreationResult.Failure.class);
+ DatabaseCreationResult.Failure failure = (DatabaseCreationResult.Failure) result;
+ assertThat(failure.phase())
+ .isIn(DatabaseCreationPort.DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
+ DatabaseCreationPort.DatabaseCreationResult.Phase.CONNECTION_TEST,
+ DatabaseCreationPort.DatabaseCreationResult.Phase.FILE_CREATION);
+ assertThat(Files.exists(missingParent)).isFalse();
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/CreateNewDatabaseUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/CreateNewDatabaseUseCase.java
new file mode 100644
index 0000000..3d40882
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/CreateNewDatabaseUseCase.java
@@ -0,0 +1,142 @@
+package de.gecheckt.pdf.umbenenner.application.port.in;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+/**
+ * Inbound-Port zum Anlegen einer neuen, leeren SQLite-Datenbankdatei und zum Umstellen
+ * der aktiven Datenbankreferenz der Anwendung auf diese neue Datei.
+ *
+ * Der Use-Case orchestriert den vollständigen, aus Anwendungssicht atomaren Ablauf:
+ *
+ * Schlägt ein Schritt fehl, bleibt die bisher aktive DB unverändert in Betrieb. Die
+ * temporäre Datei wird im Fehlerfall zuverlässig entfernt.
+ */
+@FunctionalInterface
+public interface CreateNewDatabaseUseCase {
+
+ /**
+ * Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und stellt
+ * die aktive Datenbankreferenz der Anwendung auf diese Datei um.
+ *
+ * @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null}
+ * sein. Bei einer bereits existierenden Datei muss der Aufrufer
+ * vorab die Bestätigung des Benutzers eingeholt haben.
+ * @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie
+ * {@code null}
+ * @throws NullPointerException wenn {@code targetFile} {@code null} ist
+ */
+ CreateNewDatabaseResult createNewDatabase(Path targetFile);
+
+ /**
+ * Versiegeltes Ergebnis-Interface für {@link CreateNewDatabaseUseCase#createNewDatabase(Path)}.
+ */
+ sealed interface CreateNewDatabaseResult
+ permits CreateNewDatabaseResult.Success,
+ CreateNewDatabaseResult.SameAsActiveDatabase,
+ CreateNewDatabaseResult.CreationFailed {
+
+ /**
+ * Erfolgsfall. Die neue Datenbank wurde angelegt, migriert, getestet und ist
+ * jetzt die aktive Datenbank der Anwendung.
+ *
+ * @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; nie
+ * {@code null}
+ */
+ record Success(Path targetFile) implements CreateNewDatabaseResult {
+
+ /**
+ * Konstruktor mit Pflichtprüfung.
+ *
+ * @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; darf
+ * nicht {@code null} sein
+ * @throws NullPointerException wenn {@code targetFile} {@code null} ist
+ */
+ public Success {
+ Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
+ }
+ }
+
+ /**
+ * Fehlerfall: Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei.
+ * Diese darf nicht überschrieben werden. Die aktive DB bleibt unverändert.
+ *
+ * @param targetFile der vom Benutzer gewählte Zielpfad; nie {@code null}
+ */
+ record SameAsActiveDatabase(Path targetFile) implements CreateNewDatabaseResult {
+
+ /**
+ * Konstruktor mit Pflichtprüfung.
+ *
+ * @param targetFile der vom Benutzer gewählte Zielpfad; darf nicht
+ * {@code null} sein
+ * @throws NullPointerException wenn {@code targetFile} {@code null} ist
+ */
+ public SameAsActiveDatabase {
+ Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
+ }
+ }
+
+ /**
+ * Fehlerfall: Beim Anlegen, Migrieren, Testen oder beim atomaren Move ist ein
+ * technischer Fehler aufgetreten. Die aktive DB bleibt unverändert; eine evtl.
+ * angelegte Temp-Datei wurde entfernt.
+ *
+ * @param phase technische Phase, in der der Fehler auftrat; nie {@code null}
+ * @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
+ * @param cause ursächliche Ausnahme; kann {@code null} sein
+ */
+ record CreationFailed(Phase phase, String message, Throwable cause)
+ implements CreateNewDatabaseResult {
+
+ /**
+ * Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
+ *
+ * @param phase technische Phase; darf nicht {@code null} sein
+ * @param message kurze, deutsche Fehlerbeschreibung; darf nicht
+ * {@code null} sein
+ * @param cause ursächliche Ausnahme; kann {@code null} sein
+ * @throws NullPointerException wenn {@code phase} oder {@code message}
+ * {@code null} ist
+ */
+ public CreationFailed {
+ Objects.requireNonNull(phase, "phase darf nicht null sein");
+ Objects.requireNonNull(message, "message darf nicht null sein");
+ }
+ }
+
+ /**
+ * Technische Phase, in der ein Fehler aufgetreten ist.
+ */
+ enum Phase {
+ /** Pfad-Sicherheitsprüfung (z. B. Auflösung über {@code toRealPath()}) ist fehlgeschlagen. */
+ PATH_RESOLUTION,
+ /** Anlage der temporären Datei ist fehlgeschlagen. */
+ FILE_CREATION,
+ /** Schema-Migration der temporären Datei ist fehlgeschlagen. */
+ SCHEMA_MIGRATION,
+ /** Verbindungstest gegen die migrierte Datei ist fehlgeschlagen. */
+ CONNECTION_TEST,
+ /**
+ * Atomarer Move der temporären Datei zum Zielpfad ist fehlgeschlagen –
+ * insbesondere wenn das Dateisystem die Kombination
+ * {@code ATOMIC_MOVE + REPLACE_EXISTING} nicht unterstützt. Es wird
+ * absichtlich kein nicht-atomarer Fallback durchgeführt.
+ */
+ ATOMIC_MOVE,
+ /** Umstellung der aktiven DB-Referenz ist fehlgeschlagen. */
+ CONTEXT_SWITCH
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ActiveDatabaseContextPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ActiveDatabaseContextPort.java
new file mode 100644
index 0000000..a5f2669
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ActiveDatabaseContextPort.java
@@ -0,0 +1,49 @@
+package de.gecheckt.pdf.umbenenner.application.port.out;
+
+import java.nio.file.Path;
+import java.util.Optional;
+
+/**
+ * Outbound-Port, der die zur Laufzeit aktive SQLite-Datenbankdatei der Anwendung kapselt.
+ *
+ * Eigentümer der „aktiven DB-Referenz" zur Laufzeit. Der Port erlaubt es, die aktive
+ * Datenbank über einen In-Memory-Override umzustellen, ohne die Konfigurationsdatei
+ * (`.properties`) zu verändern. Die GUI nutzt diesen Mechanismus, damit nach dem Anlegen
+ * einer neuen Datenbank sofort sämtliche DB-Operationen (Verlauf, Reset, Löschen,
+ * Verarbeitungsläufe) gegen die neue Datei laufen, bevor der Benutzer die Konfiguration
+ * speichert.
+ *
+ * Architekturgrenze: Der Port arbeitet ausschließlich mit
+ * {@link java.nio.file.Path} und kennt keine JDBC- oder SQLite-spezifischen Typen.
+ * Wie die Implementierung den Override technisch wirksam macht (z. B. durch Ersetzen
+ * der JDBC-URL beim Verdrahten neuer SQLite-Adapter), ist Adapter-Detail.
+ */
+public interface ActiveDatabaseContextPort {
+
+ /**
+ * Stellt die aktive SQLite-Datenbankdatei der Anwendung um.
+ *
+ * Nach dem Aufruf verwenden alle nachfolgenden DB-Operationen die übergebene Datei
+ * als aktive Datenbank, sofern keine andere Datei explizit übergeben wird.
+ *
+ * @param newDbFile absoluter Pfad der neuen aktiven Datenbankdatei; darf nicht
+ * {@code null} sein. Die Datei muss zum Zeitpunkt des Aufrufs
+ * existieren, ein gültiges SQLite-Schema enthalten und lesbar sein
+ * (Verbindung muss bereits durch den Aufrufer verifiziert worden sein).
+ * @throws NullPointerException wenn {@code newDbFile} {@code null} ist
+ */
+ void switchActiveDatabase(Path newDbFile);
+
+ /**
+ * Liefert den aktuell aktiven DB-Pfad als Override, sofern einer gesetzt wurde.
+ *
+ * Solange kein Override gesetzt wurde, gilt die in der jeweiligen
+ * {@link de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration}
+ * konfigurierte Datenbankdatei. Erst nach dem ersten Aufruf von
+ * {@link #switchActiveDatabase(Path)} liefert diese Methode einen nicht-leeren Wert.
+ *
+ * @return das gesetzte Override (nicht-leer) oder {@link Optional#empty()}, wenn die
+ * konfigurierte Datenbank weiterhin verwendet werden soll; nie {@code null}
+ */
+ Optional
+ * Der Aufrufer (Use-Case) verantwortet die Lebensdauer der temporären Datei: er wählt den
+ * Pfad, übergibt ihn an diesen Port und führt nach Erfolg den atomaren Move auf den
+ * endgültigen Zieldateipfad selbst aus. Der Adapter beschränkt sich strikt auf:
+ *
+ * Architekturgrenze: Provider- und SQLite-spezifische Details
+ * (JDBC-URL-Schema, DataSource-Konfiguration, Flyway-Konfiguration) bleiben
+ * ausschließlich im Adapter. Der Port arbeitet mit einem opaken {@link Path} und gibt
+ * ein versiegeltes Ergebnis zurück.
+ */
+public interface DatabaseCreationPort {
+
+ /**
+ * Erstellt eine neue, leere SQLite-Datenbankdatei am übergebenen temporären
+ * Zielpfad und führt eine vollständige Schema-Migration auf den neuesten Stand aus.
+ *
+ * Bei Fehlern in einem der Teilschritte (Anlage, Migration, Verbindungstest) wird
+ * die temporäre Datei zuverlässig wieder entfernt; aufrufende Komponenten müssen
+ * diesen Aufräumschritt nicht selbst durchführen.
+ *
+ * @param tempFile Pfad der zu erstellenden temporären SQLite-Datei; darf nicht
+ * {@code null} sein. Die Datei darf vor dem Aufruf noch nicht
+ * existieren; das Elternverzeichnis muss existieren und schreibbar
+ * sein.
+ * @return ein versiegeltes Ergebnis: {@link DatabaseCreationResult.Success} bei Erfolg
+ * oder {@link DatabaseCreationResult.Failure} mit Fehlerklasse und Meldung
+ * im Fehlerfall; nie {@code null}.
+ * @throws NullPointerException wenn {@code tempFile} {@code null} ist
+ */
+ DatabaseCreationResult createAndInitialize(Path tempFile);
+
+ /**
+ * Versiegeltes Ergebnis-Interface für {@link DatabaseCreationPort#createAndInitialize(Path)}.
+ */
+ sealed interface DatabaseCreationResult
+ permits DatabaseCreationResult.Success, DatabaseCreationResult.Failure {
+
+ /**
+ * Erfolgsergebnis. Die temporäre Datei wurde erfolgreich erstellt, migriert
+ * und durch den Verbindungstest verifiziert. Der Aufrufer kann sie nun atomar
+ * an den endgültigen Zielpfad verschieben.
+ *
+ * @param tempFile der temporäre, erfolgreich migrierte Pfad; nie {@code null}
+ */
+ record Success(Path tempFile) implements DatabaseCreationResult {
+
+ /**
+ * Konstruktor mit Pflichtprüfung.
+ *
+ * @param tempFile der temporäre, erfolgreich migrierte Pfad; darf nicht
+ * {@code null} sein
+ * @throws NullPointerException wenn {@code tempFile} {@code null} ist
+ */
+ public Success {
+ Objects.requireNonNull(tempFile, "tempFile darf nicht null sein");
+ }
+ }
+
+ /**
+ * Fehlerergebnis. Die temporäre Datei wurde – falls bereits angelegt – wieder
+ * entfernt; die aktive DB der Anwendung wurde nicht angetastet.
+ *
+ * @param phase die Phase, in der der Fehler auftrat; nie {@code null}
+ * @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
+ * @param cause ursächliche Ausnahme; kann {@code null} sein
+ */
+ record Failure(Phase phase, String message, Throwable cause) implements DatabaseCreationResult {
+
+ /**
+ * Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
+ *
+ * @param phase die Phase, in der der Fehler auftrat; darf nicht {@code null} sein
+ * @param message kurze, deutsche Fehlerbeschreibung; darf nicht {@code null} sein
+ * @param cause ursächliche Ausnahme; kann {@code null} sein
+ * @throws NullPointerException wenn {@code phase} oder {@code message} {@code null} ist
+ */
+ public Failure {
+ Objects.requireNonNull(phase, "phase darf nicht null sein");
+ Objects.requireNonNull(message, "message darf nicht null sein");
+ }
+ }
+
+ /**
+ * Phase der Erstellung einer neuen Datenbank, in der ein Fehler auftrat.
+ */
+ enum Phase {
+ /** Die temporäre Datei konnte nicht erzeugt oder beschrieben werden. */
+ FILE_CREATION,
+ /** Die Schema-Migration (Flyway) ist fehlgeschlagen. */
+ SCHEMA_MIGRATION,
+ /** Der nachgelagerte Verbindungstest ist fehlgeschlagen. */
+ CONNECTION_TEST
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCase.java
new file mode 100644
index 0000000..2ed78de
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCase.java
@@ -0,0 +1,307 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+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.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase;
+import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
+
+/**
+ * Standardimplementierung des {@link CreateNewDatabaseUseCase}.
+ *
+ * Orchestriert den vollständigen Anlage- und Wechselvorgang einer neuen, leeren
+ * SQLite-Datenbankdatei und delegiert die technischen Teilschritte an die Ports
+ * {@link DatabaseCreationPort} und {@link ActiveDatabaseContextPort}. Der Adapter
+ * darunter (z. B. SQLite/Flyway) bleibt für den Use-Case unsichtbar.
+ *
+ *
+ * Diese Indirektion erlaubt es dem Bootstrap, sowohl den
+ * {@link ActiveDatabaseContextPort}-Override als auch den Wert aus der geladenen
+ * Konfigurationsdatei zu berücksichtigen, ohne dass der Use-Case Konfigurationstypen
+ * kennen muss.
+ */
+ @FunctionalInterface
+ public interface ActiveDatabasePathSupplier {
+
+ /**
+ * Liefert den Pfad der aktuell aktiven SQLite-Datei.
+ *
+ * @return den absoluten Pfad der aktiven Datei; nie {@code null}
+ */
+ Path get();
+ }
+
+ /**
+ * Erzeugt den Use-Case mit den drei erforderlichen Ports/Lieferanten.
+ *
+ * @param databaseCreationPort Port zum Anlegen und Initialisieren der Temp-Datei;
+ * darf nicht {@code null} sein
+ * @param activeDatabaseContextPort Port zum Umstellen der aktiven DB-Referenz;
+ * darf nicht {@code null} sein
+ * @param activeDatabasePathSupplier Lieferant für den Pfad der aktuell aktiven
+ * SQLite-Datei; darf nicht {@code null} sein
+ * @throws NullPointerException wenn ein Parameter {@code null} ist
+ */
+ public DefaultCreateNewDatabaseUseCase(DatabaseCreationPort databaseCreationPort,
+ ActiveDatabaseContextPort activeDatabaseContextPort,
+ ActiveDatabasePathSupplier activeDatabasePathSupplier) {
+ this.databaseCreationPort = Objects.requireNonNull(databaseCreationPort,
+ "databaseCreationPort darf nicht null sein");
+ this.activeDatabaseContextPort = Objects.requireNonNull(activeDatabaseContextPort,
+ "activeDatabaseContextPort darf nicht null sein");
+ this.activeDatabasePathSupplier = Objects.requireNonNull(activeDatabasePathSupplier,
+ "activeDatabasePathSupplier darf nicht null sein");
+ }
+
+ /**
+ * Orchestriert den vollständigen Anlage- und Wechselvorgang.
+ *
+ * @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null} sein
+ * @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie {@code null}
+ */
+ @Override
+ public CreateNewDatabaseResult createNewDatabase(Path targetFile) {
+ Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
+ Path absoluteTarget = targetFile.toAbsolutePath().normalize();
+ LOG.info("Neue Datenbank anlegen: angeforderter Zielpfad = {}", absoluteTarget);
+
+ // Schritt 1: Pfad-Sicherheitsprüfung
+ Path activeDb = activeDatabasePathSupplier.get();
+ if (activeDb == null) {
+ LOG.error("Aktiver Datenbankpfad ist nicht ermittelbar – Anlage abgebrochen.");
+ return new CreateNewDatabaseResult.CreationFailed(
+ CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
+ "Aktiver Datenbankpfad konnte nicht ermittelt werden.",
+ null);
+ }
+ Path absoluteActive = activeDb.toAbsolutePath().normalize();
+ boolean sameFile;
+ try {
+ sameFile = isSameFile(absoluteActive, absoluteTarget);
+ } catch (IOException e) {
+ LOG.error("Pfad-Sicherheitsprüfung fehlgeschlagen: {}", e.getMessage(), e);
+ return new CreateNewDatabaseResult.CreationFailed(
+ CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
+ "Pfad-Sicherheitsprüfung fehlgeschlagen: " + e.getMessage(),
+ e);
+ }
+ if (sameFile) {
+ LOG.warn("Anlage abgelehnt: Zielpfad entspricht der aktuell aktiven Datenbank: {}",
+ absoluteTarget);
+ return new CreateNewDatabaseResult.SameAsActiveDatabase(absoluteTarget);
+ }
+
+ // Schritt 2: Temp-Datei im Zielverzeichnis vorbereiten
+ Path parent = absoluteTarget.getParent();
+ if (parent == null) {
+ LOG.error("Zielpfad besitzt kein Elternverzeichnis: {}", absoluteTarget);
+ return new CreateNewDatabaseResult.CreationFailed(
+ CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
+ "Zielpfad besitzt kein Elternverzeichnis.",
+ null);
+ }
+ try {
+ if (!Files.isDirectory(parent)) {
+ Files.createDirectories(parent);
+ }
+ } catch (IOException e) {
+ LOG.error("Zielverzeichnis konnte nicht angelegt werden: {}", parent, e);
+ return new CreateNewDatabaseResult.CreationFailed(
+ CreateNewDatabaseResult.Phase.FILE_CREATION,
+ "Zielverzeichnis konnte nicht angelegt werden: " + e.getMessage(),
+ e);
+ }
+ Path tempFile = parent.resolve(absoluteTarget.getFileName().toString()
+ + ".new-" + UUID.randomUUID() + ".tmp");
+
+ // Schritt 3: Adapter führt Anlage + Schema-Migration + Verbindungstest aus
+ DatabaseCreationPort.DatabaseCreationResult creationResult;
+ try {
+ creationResult = databaseCreationPort.createAndInitialize(tempFile);
+ } catch (RuntimeException e) {
+ LOG.error("Unerwarteter Fehler beim Anlegen der temporären Datenbank: {}",
+ e.getMessage(), e);
+ deleteTempQuietly(tempFile);
+ return new CreateNewDatabaseResult.CreationFailed(
+ CreateNewDatabaseResult.Phase.FILE_CREATION,
+ "Unerwarteter Fehler beim Anlegen der temporären Datenbank: " + e.getMessage(),
+ e);
+ }
+ if (creationResult instanceof DatabaseCreationPort.DatabaseCreationResult.Failure failure) {
+ CreateNewDatabaseResult.Phase phase = mapPhase(failure.phase());
+ LOG.error("Anlage der neuen Datenbank fehlgeschlagen ({}): {}",
+ failure.phase(), failure.message());
+ deleteTempQuietly(tempFile);
+ return new CreateNewDatabaseResult.CreationFailed(phase, failure.message(),
+ failure.cause());
+ }
+
+ // Schritt 4: atomarer Move auf Zielpfad
+ try {
+ Files.move(tempFile, absoluteTarget,
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException e) {
+ LOG.error("Atomarer Move nicht unterstützt für Zielpfad {}: {}",
+ absoluteTarget, e.getMessage(), e);
+ deleteTempQuietly(tempFile);
+ return new CreateNewDatabaseResult.CreationFailed(
+ CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
+ "Atomarer Move (ATOMIC_MOVE + REPLACE_EXISTING) wird vom Dateisystem nicht "
+ + "unterstützt. Kein nicht-atomarer Fallback. Ziel: "
+ + absoluteTarget,
+ e);
+ } catch (IOException e) {
+ LOG.error("Atomarer Move fehlgeschlagen: {}", e.getMessage(), e);
+ deleteTempQuietly(tempFile);
+ return new CreateNewDatabaseResult.CreationFailed(
+ CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
+ "Verschieben der temporären Datenbank zum Zielpfad fehlgeschlagen: "
+ + e.getMessage(),
+ e);
+ }
+
+ // Schritt 5: Aktive DB-Referenz umstellen
+ try {
+ activeDatabaseContextPort.switchActiveDatabase(absoluteTarget);
+ } catch (RuntimeException e) {
+ LOG.error("Umstellen der aktiven DB-Referenz fehlgeschlagen: {}", e.getMessage(), e);
+ return new CreateNewDatabaseResult.CreationFailed(
+ CreateNewDatabaseResult.Phase.CONTEXT_SWITCH,
+ "Aktive DB-Referenz konnte nicht umgestellt werden: " + e.getMessage(),
+ e);
+ }
+
+ LOG.info("Neue Datenbank erfolgreich angelegt und aktiviert: {}", absoluteTarget);
+ return new CreateNewDatabaseResult.Success(absoluteTarget);
+ }
+
+ /**
+ * Vergleicht zwei Datenbankpfade mit Berücksichtigung von Symlinks und
+ * (auf Windows) Case-Insensitivität.
+ *
+ * Existieren beide Dateien, wird {@code Files.isSameFile(...)} verwendet.
+ * Existiert eine der beiden Dateien (typischerweise das Ziel) noch nicht, werden
+ * Elternverzeichnisse via {@link Path#toRealPath(java.nio.file.LinkOption...)}
+ * aufgelöst und mit den Dateinamen kombiniert verglichen. Auf Windows erfolgt der
+ * abschließende String-Vergleich case-insensitive.
+ *
+ * @param a Pfad A; darf nicht {@code null} sein
+ * @param b Pfad B; darf nicht {@code null} sein
+ * @return {@code true}, wenn beide Pfade auf dieselbe Datei zeigen
+ * @throws IOException bei Auflösungsfehlern existierender Pfadbestandteile
+ */
+ static boolean isSameFile(Path a, Path b) throws IOException {
+ Objects.requireNonNull(a, "a darf nicht null sein");
+ Objects.requireNonNull(b, "b darf nicht null sein");
+ if (Files.exists(a) && Files.exists(b)) {
+ return Files.isSameFile(a, b);
+ }
+ Path realA = resolveBest(a);
+ Path realB = resolveBest(b);
+ if (isWindows()) {
+ return realA.toString().equalsIgnoreCase(realB.toString());
+ }
+ return realA.equals(realB);
+ }
+
+ /**
+ * Löst die existierenden Bestandteile eines Pfades soweit möglich real auf und
+ * normalisiert den Rest. Wird verwendet, wenn die Datei selbst noch nicht existiert.
+ *
+ * @param path der zu normalisierende Pfad
+ * @return ein bestmöglich aufgelöster, normalisierter Pfad
+ * @throws IOException bei {@link Path#toRealPath(java.nio.file.LinkOption...)}-Fehlern
+ */
+ private static Path resolveBest(Path path) throws IOException {
+ if (Files.exists(path)) {
+ return path.toRealPath();
+ }
+ Path parent = path.toAbsolutePath().normalize().getParent();
+ Path fileName = path.getFileName();
+ if (parent != null && Files.exists(parent)) {
+ return parent.toRealPath().resolve(fileName == null ? "" : fileName.toString());
+ }
+ return path.toAbsolutePath().normalize();
+ }
+
+ /**
+ * Liefert {@code true}, wenn die laufende JVM auf Windows läuft.
+ *
+ * @return {@code true}, wenn Windows; sonst {@code false}
+ */
+ private static boolean isWindows() {
+ return OS_NAME.contains("win");
+ }
+
+ private void deleteTempQuietly(Path tempFile) {
+ if (tempFile == null) {
+ return;
+ }
+ try {
+ Files.deleteIfExists(tempFile);
+ } catch (IOException e) {
+ LOG.warn("Temporäre Datenbankdatei konnte nicht gelöscht werden: {} – {}",
+ tempFile, e.getMessage());
+ }
+ }
+
+ private static CreateNewDatabaseResult.Phase mapPhase(
+ DatabaseCreationPort.DatabaseCreationResult.Phase phase) {
+ return switch (phase) {
+ case FILE_CREATION -> CreateNewDatabaseResult.Phase.FILE_CREATION;
+ case SCHEMA_MIGRATION -> CreateNewDatabaseResult.Phase.SCHEMA_MIGRATION;
+ case CONNECTION_TEST -> CreateNewDatabaseResult.Phase.CONNECTION_TEST;
+ };
+ }
+
+ /**
+ * Liefert das gesetzte Override (sofern vorhanden), für Diagnose- und Logging-Zwecke.
+ * Nicht Teil der öffentlichen Use-Case-API.
+ *
+ * @return das Override aus dem {@link ActiveDatabaseContextPort}; nie {@code null}
+ */
+ Optional
+ * Prüft den Orchestrierungsablauf, die Pfad-Sicherheitsprüfung, das Aufräumen der
+ * temporären Datei im Fehlerfall und das Umstellen der aktiven DB-Referenz im
+ * Erfolgsfall. Die DB-Adapter werden über Stubs ersetzt, damit die Tests ohne
+ * SQLite-/Flyway-Infrastruktur laufen.
+ */
+class DefaultCreateNewDatabaseUseCaseTest {
+
+ @Test
+ void constructor_shouldThrowNullPointerException_whenAnyPortIsNull() {
+ DatabaseCreationPort creation = tempFile -> new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
+ ActiveDatabaseContextPort context = stubActiveContext();
+ DefaultCreateNewDatabaseUseCase.ActiveDatabasePathSupplier supplier = () -> Path.of(".");
+
+ assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(null, context, supplier))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("databaseCreationPort");
+ assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(creation, null, supplier))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("activeDatabaseContextPort");
+ assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(creation, context, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("activeDatabasePathSupplier");
+ }
+
+ @Test
+ void createNewDatabase_shouldThrowNullPointerException_whenTargetIsNull() {
+ DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
+ tempFile -> new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile),
+ stubActiveContext(),
+ () -> Path.of("active.sqlite"));
+
+ assertThatThrownBy(() -> useCase.createNewDatabase(null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void createNewDatabase_shouldRejectSameAsActiveDatabase(@TempDir Path tempDir) throws IOException {
+ Path active = tempDir.resolve("active.sqlite");
+ Files.writeString(active, "stub");
+
+ AtomicReference
+ * Solange kein Override gesetzt ist, verhalten sich alle DB-Adapter wie
+ * bisher (Pfad aus der jeweils geladenen {@link StartConfiguration}).
+ */
+ private final SqliteActiveDatabaseContextAdapter activeDatabaseContext = new SqliteActiveDatabaseContextAdapter();
+
+ /**
+ * Adapter zum Anlegen einer neuen, leeren SQLite-Datenbank. Zustandslos und
+ * threadsicher; wird im GUI-Pfad pro Anlage-Aufruf wiederverwendet.
+ */
+ private final DatabaseCreationPort databaseCreationPort = new SqliteDatabaseCreationAdapter();
+
/**
* Functional interface encapsulating the legacy configuration migration step.
*
@@ -416,7 +440,7 @@ public class BootstrapRunner {
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
- String jdbcUrl = buildJdbcUrl(startConfig);
+ String jdbcUrl = resolveActiveJdbcUrl(startConfig);
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
@@ -823,6 +847,7 @@ public class BootstrapRunner {
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui;
GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui;
+ GuiCreateNewDatabasePort createNewDatabasePort = this::createNewDatabaseForGui;
GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui;
GuiDeleteDocumentHistoryPort deleteHistoryPort = this::deleteDocumentHistoryForGui;
// Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start
@@ -852,7 +877,8 @@ public class BootstrapRunner {
historyDetailsPort,
historyResetPort,
deleteHistoryPort,
- this::buildGuiPromptEditorPort);
+ this::buildGuiPromptEditorPort,
+ createNewDatabasePort);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -883,7 +909,8 @@ public class BootstrapRunner {
historyDetailsPort,
historyResetPort,
deleteHistoryPort,
- this::buildGuiPromptEditorPort);
+ this::buildGuiPromptEditorPort,
+ createNewDatabasePort);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -897,7 +924,7 @@ public class BootstrapRunner {
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
- this::buildGuiPromptEditorPort);
+ this::buildGuiPromptEditorPort, createNewDatabasePort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -924,7 +951,8 @@ public class BootstrapRunner {
historyDetailsPort,
historyResetPort,
deleteHistoryPort,
- this::buildGuiPromptEditorPort);
+ this::buildGuiPromptEditorPort,
+ createNewDatabasePort);
}
}
@@ -1171,7 +1199,7 @@ public class BootstrapRunner {
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
- String jdbcUrl = buildJdbcUrl(config);
+ String jdbcUrl = resolveActiveJdbcUrl(config);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
ProcessingLogger resetLogger = new Log4jProcessingLogger(
DefaultResetDocumentStatusUseCase.class,
@@ -1229,7 +1257,7 @@ public class BootstrapRunner {
*/
private ManualFileRenameUseCase buildProductionManualFileRenameUseCase(
StartConfiguration startConfig) {
- String jdbcUrl = buildJdbcUrl(startConfig);
+ String jdbcUrl = resolveActiveJdbcUrl(startConfig);
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
@@ -1263,7 +1291,7 @@ public class BootstrapRunner {
*/
private ManualFileCopyUseCase buildProductionManualFileCopyUseCase(
StartConfiguration startConfig) {
- String jdbcUrl = buildJdbcUrl(startConfig);
+ String jdbcUrl = resolveActiveJdbcUrl(startConfig);
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
@@ -1442,7 +1470,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
- String jdbcUrl = buildJdbcUrl(config);
+ String jdbcUrl = resolveActiveJdbcUrl(config);
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
ResolveHistoricalDocumentContextUseCase useCase =
@@ -1475,7 +1503,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
- String jdbcUrl = buildJdbcUrl(config);
+ String jdbcUrl = resolveActiveJdbcUrl(config);
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(historyQueryPort);
return useCase.loadOverview(query);
@@ -1505,7 +1533,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
- String jdbcUrl = buildJdbcUrl(config);
+ String jdbcUrl = resolveActiveJdbcUrl(config);
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(historyQueryPort);
return useCase.loadDetails(fingerprint);
@@ -1536,7 +1564,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
- String jdbcUrl = buildJdbcUrl(config);
+ String jdbcUrl = resolveActiveJdbcUrl(config);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(unitOfWorkPort);
@@ -1568,7 +1596,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
- String jdbcUrl = buildJdbcUrl(config);
+ String jdbcUrl = resolveActiveJdbcUrl(config);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(unitOfWorkPort);
@@ -1582,6 +1610,53 @@ public class BootstrapRunner {
}
}
+ /**
+ * Bridge-Methode zum Anlegen einer neuen, leeren SQLite-Datenbank für die GUI.
+ *
+ * Verdrahtet pro Aufruf einen frischen {@link DefaultCreateNewDatabaseUseCase} mit dem
+ * gemeinsam gehaltenen {@link #databaseCreationPort} und dem
+ * {@link #activeDatabaseContext}. Die zugrundeliegende Liefer-Lambda für den aktiven
+ * DB-Pfad bevorzugt das Override im Kontext-Port; ist keines gesetzt, fällt sie auf den
+ * {@code sqliteFile}-Wert der aus {@code configFilePath} geladenen Konfiguration zurück.
+ *
+ * Pfad-Validierungs- und Move-Schritte verbleiben im Use-Case; der Adapter führt
+ * Anlage, Migration und Verbindungstest aus.
+ *
+ * @param configFilePath der aktuell in der GUI bekannte Konfigurationspfad oder
+ * {@code null}, wenn noch keine Konfiguration geladen wurde
+ * @param targetFile der vom Benutzer gewählte Zielpfad; darf nicht {@code null} sein
+ * @return strukturiertes Ergebnis des Use-Cases; nie {@code null}
+ */
+ CreateNewDatabaseUseCase.CreateNewDatabaseResult createNewDatabaseForGui(
+ Path configFilePath, Path targetFile) {
+ Objects.requireNonNull(targetFile, "targetFile must not be null");
+ LOG.info("GUI-Datenbankanlage: angeforderter Zielpfad = {}", targetFile);
+ DefaultCreateNewDatabaseUseCase.ActiveDatabasePathSupplier activeDbSupplier = () -> {
+ Optional
+ * Diese Variante berücksichtigt {@em kein} Override – sie wird ausschließlich für
+ * den Headless-Pfad und für GUI-Tests aufgerufen, in denen kein
+ * {@link ActiveDatabaseContextPort} verfügbar ist.
*
* @param config the startup configuration containing the SQLite file path
* @return the JDBC URL in the form {@code jdbc:sqlite:/path/to/file.db}
*/
static String buildJdbcUrl(StartConfiguration config) {
- return "jdbc:sqlite:" + config.sqliteFile().toAbsolutePath().toString().replace('\\', '/');
+ return buildSqliteJdbcUrl(config.sqliteFile());
+ }
+
+ /**
+ * Liefert die JDBC-URL der zur Laufzeit aktiven SQLite-Datenbank.
+ *
+ * Hat der {@link ActiveDatabaseContextPort} einen Override gesetzt (z. B. nachdem
+ * der Benutzer im GUI eine neue Datenbank angelegt hat), wird dieser verwendet.
+ * Andernfalls greift der Wert aus der übergebenen {@link StartConfiguration}.
+ *
+ * @param config die geladene Startup-Konfiguration; nie {@code null}
+ * @return die JDBC-URL der aktiven SQLite-Datei
+ */
+ String resolveActiveJdbcUrl(StartConfiguration config) {
+ Optional
+ * Hält einen prozessweiten, threadsicheren Override-Pfad, der in der Bootstrap-Schicht
+ * Vorrang vor dem in der {@code .properties}-Datei konfigurierten SQLite-Pfad hat.
+ * Solange kein Override gesetzt ist, verhält sich die Anwendung wie bisher: alle
+ * SQLite-Adapter werden mit der aus der jeweils geladenen Konfigurationsdatei
+ * abgeleiteten JDBC-URL verdrahtet.
+ *
+ * Setzt der Use-Case
+ * {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultCreateNewDatabaseUseCase}
+ * den Override über {@link #switchActiveDatabase(Path)}, verwenden alle nachfolgenden
+ * GUI-DB-Operationen (History, Reset, Löschen, Verarbeitungsläufe) die hinterlegte
+ * Datei – auch wenn der Benutzer die Konfigurationsdatei noch nicht gespeichert hat.
+ *
+ * Die gehaltene Referenz lebt für die Dauer des laufenden GUI-Prozesses. Sie ist
+ * nicht persistent: nach einem Neustart greift wieder der Wert aus der
+ * Konfigurationsdatei.
+ *
+ * Alle Lese- und Schreibzugriffe gehen über eine {@link AtomicReference}; die Klasse
+ * ist daher uneingeschränkt threadsicher.
+ */
+public class SqliteActiveDatabaseContextAdapter implements ActiveDatabaseContextPort {
+
+ private static final Logger LOG = LogManager.getLogger(SqliteActiveDatabaseContextAdapter.class);
+
+ private final AtomicReference
+ * Prüft das Setzen, Auslesen und Zurücksetzen des Override-Pfads sowie die Pflicht-Prüfung
+ * gegen {@code null}-Argumente.
+ */
+class SqliteActiveDatabaseContextAdapterTest {
+
+ @Test
+ void activeDatabaseOverride_returnsEmpty_initially() {
+ SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
+ assertThat(adapter.activeDatabaseOverride()).isEmpty();
+ }
+
+ @Test
+ void switchActiveDatabase_setsAbsoluteOverride() {
+ SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
+ Path target = Path.of("data/foo.sqlite");
+
+ adapter.switchActiveDatabase(target);
+
+ Optional
+ *
+ * Architekturgrenze
+ *
+ *
+ *
+ *
+ * Atomarität
+ * Aus Anwendungssicht ist der Wechsel atomar:
+ *
+ *
+ *
+ * Pfad-Sicherheitsprüfung
+ * Aktive DB und Zielpfad werden über {@link Path#toRealPath(java.nio.file.LinkOption...)}
+ * normalisiert verglichen. Für noch nicht existierende Dateien wird das Elternverzeichnis
+ * real aufgelöst und der Dateiname normalisiert verglichen. Auf Windows erfolgt der
+ * Vergleich case-insensitive.
+ */
+public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase {
+
+ private static final Logger LOG = LogManager.getLogger(DefaultCreateNewDatabaseUseCase.class);
+ private static final String OS_NAME = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+
+ private final DatabaseCreationPort databaseCreationPort;
+ private final ActiveDatabaseContextPort activeDatabaseContextPort;
+ private final ActiveDatabasePathSupplier activeDatabasePathSupplier;
+
+ /**
+ * Liefert den Pfad der aktuell aktiven SQLite-Datei.
+ * Lebensdauer
+ * Threading
+ *