Lege neue leere SQLite-Datenbank atomar via Use-Case und GUI an

Neuer Menüpunkt „Datenbank → Neue Datenbank anlegen…" mit FileChooser,
normalisierter Pfadprüfung gegen die aktive DB, gesammelter Überschreib-
Bestätigung, DB-Busy-Sperre auf Verlauf-Tab, Flyway-Migration auf den
neuesten Stand gegen eine Temp-Datei, Verbindungstest, atomarem Move
(ATOMIC_MOVE + REPLACE_EXISTING) und Umstellen der aktiven DB-Referenz
über einen neuen ActiveDatabaseContextPort. Konfig-Tab wechselt nach
Wechsel automatisch in den Dirty-State; Hinweismeldung mit Speichern-
Aufforderung wird im zentralen Meldungsbereich angezeigt.

Architektur entspricht Fall B aus der Spezifikation: Bootstrap hält den
Override prozessweit und verwendet ihn in resolveActiveJdbcUrl statt
des Werts aus der .properties-Datei. Bei Fehlern wird die Temp-Datei
zuverlässig entfernt; die aktive DB bleibt unverändert in Betrieb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 16:52:54 +02:00
parent 90d95b9ff8
commit 3876e647b2
15 changed files with 1793 additions and 20 deletions
@@ -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()}).
* <p>
* 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…".
* <p>
* Ö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.
* <p>
* 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.
* <p>
* 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<ButtonType> choice = showConfirmation(
"Datei überschreiben?",
"Die Datei existiert bereits:\n" + requestedTarget
+ "\n\nDie vorhandene Datei wird durch eine neue, leere SQLite-Datenbank ersetzt.\nFortfahren?",
abbrechen,
ueberschreiben);
if (choice.isEmpty() || !choice.get().equals(ueberschreiben)) {
LOG.info("GUI-Editor: Anlage einer neuen Datenbank vom Benutzer abgebrochen.");
return;
}
}
startCreateNewDatabaseWorker(requestedTarget);
}
/**
* Aktiviert die DB-Busy-Sperre auf der Oberfläche und reicht den eigentlichen
* Aufruf des {@link GuiCreateNewDatabasePort} an einen Daemon-Worker-Thread weiter.
*
* @param targetFile der bereits geprüfte Zielpfad; nie {@code null}
*/
private void startCreateNewDatabaseWorker(Path targetFile) {
dbBusyForDatabaseCreation.set(true);
applyDbBusyLock();
showStatusMessage("Neue SQLite-Datenbank wird angelegt …");
createNewDatabaseExecutor.submit(() -> {
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
.CreateNewDatabaseResult result;
try {
result = createNewDatabasePort.createNewDatabase(loadedConfigurationPath(), targetFile);
} catch (RuntimeException e) {
LOG.error("GUI-Editor: Unerwarteter Fehler beim Anlegen der neuen Datenbank: {}",
e.getMessage(), e);
Platform.runLater(() -> {
dbBusyForDatabaseCreation.set(false);
applyDbBusyLock();
showError("Neue Datenbank konnte nicht angelegt werden: " + safeMessage(e));
});
return;
}
Platform.runLater(() -> handleCreateNewDatabaseResult(targetFile, result));
});
}
/**
* Übersetzt das Ergebnis des Use-Cases in die UI-Reaktion: Dirty-State,
* Statuszeile, Verlauf-Reload, Hinweismeldung oder Fehlerdialog.
*
* @param targetFile der vom Benutzer gewählte Zielpfad
* @param result das Ergebnis des Use-Cases
*/
private void handleCreateNewDatabaseResult(
Path targetFile,
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
.CreateNewDatabaseResult result) {
try {
switch (result) {
case de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Success success -> {
Path effectiveTarget = success.targetFile();
LOG.info("GUI-Editor: Neue Datenbank ist aktiv: {}", effectiveTarget);
// Konfigurationsmodell aktualisieren → Dirty-State
GuiConfigurationValues updated = editorState.values()
.withSqliteFile(effectiveTarget.toString());
updateValues(updated);
// Verlauf-Tab neu laden, damit die neue (leere) DB sichtbar wird
if (historyTab != null) {
historyTab.reloadAfterDatabaseSwitch();
}
// Hinweismeldung im zentralen Meldungsbereich
pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.INFO,
"Neue Datenbank ist aktiv. Konfiguration speichern, damit "
+ "diese DB beim nächsten Start verwendet wird.",
"Datenbank-Anlage"));
refreshAfterValidation();
showStatusMessage("Neue Datenbank ist aktiv: " + effectiveTarget);
}
case de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.SameAsActiveDatabase same -> {
LOG.warn("GUI-Editor: Anlage abgelehnt Zielpfad ist die aktuell aktive DB: {}",
same.targetFile());
showError("Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei. "
+ "Bitte einen anderen Pfad wählen.");
}
case de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed failure -> {
LOG.error("GUI-Editor: Anlage fehlgeschlagen ({}): {}",
failure.phase(), failure.message());
showError("Neue Datenbank konnte nicht angelegt werden: " + failure.message());
}
}
} finally {
dbBusyForDatabaseCreation.set(false);
applyDbBusyLock();
}
}
/**
* Wendet die aktuelle DB-Busy-Sperre auf die betroffenen UI-Komponenten an.
* <p>
* 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.
@@ -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}.
* <p>
* Dieses Interface ist <em>kein</em> 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.
* <p>
* <strong>Threading:</strong> 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);
}
@@ -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() {
@@ -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.
* <p>
* Aktuell enthält sie genau einen Eintrag: das Menü „Datenbank" mit der Aktion
* „Neue Datenbank anlegen…". Diese delegiert an
* {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}.
* <p>
* 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.
@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* Wird vom Workspace nach erfolgreichem Datenbank-Wechsel aufgerufen, damit der
* Detailbereich und die Liste die neue (leere) Datenbank wiedergeben.
* <p>
* 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
// =========================================================================