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 6f0f08f..fb79cbb 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 @@ -51,6 +51,11 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; * context for documents that were skipped in the current run, and the resolved application * version string that the status bar displays at the bottom of the main window. *

+ * The optional {@code applicationContextError} carries a human-readable German error + * message when the bootstrap-side application run context could not be initialised at + * startup (e.g., invalid or incomplete configuration). An empty value signals that the + * run context was built successfully and batch runs can be launched immediately. + *

* 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. */ @@ -78,7 +83,8 @@ public record GuiStartupContext( GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort, GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort, GuiPromptEditorPortFactory promptEditorPortFactory, - GuiCreateNewDatabasePort createNewDatabasePort) { + GuiCreateNewDatabasePort createNewDatabasePort, + Optional applicationContextError) { /** * Creates a fully wired startup context. @@ -111,10 +117,13 @@ public record GuiStartupContext( * {@code null} sein * @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel; * darf nicht {@code null} sein + * @param applicationContextError optional error message when the application run context + * could not be initialised at startup; {@code null} becomes empty */ public GuiStartupContext { initialState = Objects.requireNonNull(initialState, "initialState must not be null"); startupNotice = startupNotice == null ? Optional.empty() : startupNotice; + applicationContextError = applicationContextError == null ? Optional.empty() : applicationContextError; configurationFileLoader = Objects.requireNonNull(configurationFileLoader, "configurationFileLoader must not be null"); configurationFileWriter = Objects.requireNonNull(configurationFileWriter, @@ -202,7 +211,7 @@ public record GuiStartupContext( noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(), noOpHistoryOverviewPort(), noOpHistoryDetailsPort(), noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(), - rejectingCreateNewDatabasePort()); + rejectingCreateNewDatabasePort(), Optional.empty()); } /** @@ -241,7 +250,7 @@ public record GuiStartupContext( noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(), noOpHistoryOverviewPort(), noOpHistoryDetailsPort(), noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(), - rejectingCreateNewDatabasePort()); + rejectingCreateNewDatabasePort(), Optional.empty()); } /** @@ -280,7 +289,7 @@ public record GuiStartupContext( noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(), noOpHistoryOverviewPort(), noOpHistoryDetailsPort(), noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(), - rejectingCreateNewDatabasePort()); + rejectingCreateNewDatabasePort(), Optional.empty()); } private static GuiBatchRunLauncher rejectingBatchRunLauncher() { @@ -403,7 +412,8 @@ public record GuiStartupContext( noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(), - rejectingCreateNewDatabasePort()); + rejectingCreateNewDatabasePort(), + Optional.empty()); } /** diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/ApplicationRunContext.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/ApplicationRunContext.java new file mode 100644 index 0000000..1af15e3 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/ApplicationRunContext.java @@ -0,0 +1,26 @@ +package de.gecheckt.pdf.umbenenner.bootstrap; + +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; + +/** + * Immutable context built once at GUI startup for a specific validated configuration file. + *

+ * Holds the fully validated {@link StartConfiguration} and the resolved JDBC URL for the + * active SQLite database. This context is created after a successful migrate → load → + * validate → schema-init sequence and is reused for all subsequent batch runs and + * database operations without repeating those steps. + *

+ * When the configuration changes (e.g., the user opens a different file), a new context + * must be built. Structural configuration changes require a GUI restart to take effect. + *

+ * Thread-safety: this record is immutable and safe for concurrent access. + */ +record ApplicationRunContext(StartConfiguration startConfiguration, String jdbcUrl) { + + ApplicationRunContext { + Objects.requireNonNull(startConfiguration, "startConfiguration must not be null"); + Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null"); + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index d1369ae..d611bf0 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -231,6 +231,21 @@ public class BootstrapRunner { */ private final DatabaseCreationPort databaseCreationPort = new SqliteDatabaseCreationAdapter(); + /** + * The application run context built once at GUI startup from the initially loaded + * configuration file. When present, it carries a fully validated + * {@link de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration} and + * the resolved JDBC URL, so that batch runs and reset operations can skip the + * migrate → load → validate → schema-init sequence for each call. + *

+ * Written by {@link #initializeApplicationRunContext(Path)} during + * {@link #buildGuiStartupContext(Optional)} and read by + * {@link #launchGuiBatchRun}, {@link #launchGuiMiniBatchRun}, and + * {@link #resetDocumentStatusForGui}. {@code volatile} ensures visibility + * across threads without explicit synchronisation on the happy path. + */ + private volatile Optional guiApplicationRunContext = Optional.empty(); + /** * Functional interface encapsulating the legacy configuration migration step. *

@@ -878,7 +893,8 @@ public class BootstrapRunner { historyResetPort, deleteHistoryPort, this::buildGuiPromptEditorPort, - createNewDatabasePort); + createNewDatabasePort, + Optional.empty()); } Path configPath = Paths.get(configPathOverride.get()); @@ -910,7 +926,8 @@ public class BootstrapRunner { historyResetPort, deleteHistoryPort, this::buildGuiPromptEditorPort, - createNewDatabasePort); + createNewDatabasePort, + Optional.empty()); } LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath()); @@ -918,13 +935,14 @@ public class BootstrapRunner { GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath); GuiPromptEditorPort promptEditorPort = buildGuiPromptEditorPort( loadedState.values().promptTemplateFile()); + Optional contextError = initializeApplicationRunContext(configPath); return new GuiStartupContext(loadedState, Optional.empty(), loader, writer, modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, miniRunLauncher, resetPort, manualRenamePort, manualCopyPort, historicalDocumentContextPort, applicationVersion, promptEditorPort, historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort, - this::buildGuiPromptEditorPort, createNewDatabasePort); + this::buildGuiPromptEditorPort, createNewDatabasePort, contextError); } catch (GuiConfigurationLoadException e) { LOG.error("GUI startup: configuration could not be loaded, starting without it: {}", e.getMessage(), e); @@ -952,7 +970,8 @@ public class BootstrapRunner { historyResetPort, deleteHistoryPort, this::buildGuiPromptEditorPort, - createNewDatabasePort); + createNewDatabasePort, + Optional.empty()); } } @@ -1032,13 +1051,176 @@ public class BootstrapRunner { }; } + /** + * Attempts to build and store an {@link ApplicationRunContext} from the given configuration + * file path. + *

+ * Runs the full init sequence (migrate → load → validate → schema-init) and, on success, + * stores the result in {@link #guiApplicationRunContext}. On any failure the field is + * cleared and an Optional carrying a human-readable German error message is returned so + * the caller can surface it in the startup context without aborting the GUI launch. + * + * @param configFilePath path to the {@code .properties} configuration file; must exist on disk + * @return {@link Optional#empty()} on success; an Optional with the error message on failure + */ + private Optional initializeApplicationRunContext(Path configFilePath) { + try { + migrateConfigurationIfNeeded(configFilePath); + StartConfiguration config = loadAndValidateConfiguration(configFilePath); + initializeSchema(config); + guiApplicationRunContext = Optional.of( + new ApplicationRunContext(config, resolveActiveJdbcUrl(config))); + LOG.info("GUI-Anwendungskontext initialisiert für Konfiguration: {}", configFilePath); + return Optional.empty(); + } catch (ConfigurationLoadingException e) { + LOG.warn("GUI-Anwendungskontext: Konfiguration konnte nicht geladen werden: {}", + e.getMessage()); + guiApplicationRunContext = Optional.empty(); + return Optional.of("Konfiguration konnte nicht geladen werden: " + e.getMessage()); + } catch (InvalidStartConfigurationException e) { + LOG.warn("GUI-Anwendungskontext: Konfiguration nicht lauffähig: {}", e.getMessage()); + guiApplicationRunContext = Optional.empty(); + return Optional.of("Konfiguration nicht lauffähig: " + e.getMessage()); + } catch (DocumentPersistenceException e) { + LOG.warn("GUI-Anwendungskontext: SQLite-Initialisierung fehlgeschlagen: {}", + e.getMessage()); + guiApplicationRunContext = Optional.empty(); + return Optional.of("SQLite konnte nicht initialisiert werden: " + e.getMessage()); + } catch (RuntimeException e) { + LOG.warn("GUI-Anwendungskontext: Unerwarteter Fehler bei Initialisierung: {}", + e.getMessage()); + guiApplicationRunContext = Optional.empty(); + return Optional.of("Unerwarteter Fehler bei der Kontextinitialisierung: " + + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + } + } + + /** + * Executes one batch run using a pre-built {@link ApplicationRunContext}. + *

+ * Skips the migrate → load → validate → schema-init sequence because the context was + * already built at startup. Only the run-lock, use-case wiring and execution are performed. + * + * @param ctx the validated application run context; must not be null + * @param progressObserver observer forwarded into the use case; must not be null + * @param cancellationToken token forwarded into the use case; must not be null + * @return the outcome for the run; never null + */ + private GuiBatchRunLaunchOutcome executeRun( + ApplicationRunContext ctx, + de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver, + de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) { + try { + RunLockPort runLockPort = runLockPortFactory.create( + resolveLockFilePath(ctx.startConfiguration())); + BatchRunContext runContext = createRunContext(); + BatchRunProcessingUseCase useCase = buildProductionBatchUseCase( + ctx.startConfiguration(), runLockPort, progressObserver, cancellationToken); + BatchRunOutcome outcome = useCase.execute(runContext); + runContext.setEndInstant(Instant.now()); + return mapGuiRunOutcome(outcome, runContext); + } catch (RuntimeException e) { + LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e); + return GuiBatchRunLaunchOutcome.failedAfterStart( + "Unerwarteter Fehler im Verarbeitungslauf: " + + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + } + } + + /** + * Executes one targeted mini-run using a pre-built {@link ApplicationRunContext}. + *

+ * Skips the migrate → load → validate → schema-init sequence because the context was + * already built at startup. Only the run-lock, use-case wiring, fingerprint filter and + * execution are performed. + * + * @param ctx the validated application run context; must not be null + * @param fingerprintFilter the set of document fingerprints to process; must not be null + * @param progressObserver observer forwarded into the use case; must not be null + * @param cancellationToken token forwarded into the use case; must not be null + * @return the outcome for the mini-run; never null + */ + private GuiBatchRunLaunchOutcome executeRunWithFilter( + ApplicationRunContext ctx, + Set fingerprintFilter, + de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver, + de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) { + try { + RunLockPort runLockPort = runLockPortFactory.create( + resolveLockFilePath(ctx.startConfiguration())); + BatchRunContext runContext = createRunContext(); + BatchRunContext filteredContext = runContext.withFingerprintFilter(fingerprintFilter); + BatchRunProcessingUseCase useCase = buildProductionBatchUseCase( + ctx.startConfiguration(), runLockPort, progressObserver, cancellationToken); + BatchRunOutcome outcome = useCase.execute(filteredContext); + filteredContext.setEndInstant(Instant.now()); + return mapGuiRunOutcome(outcome, filteredContext); + } catch (RuntimeException e) { + LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e); + return GuiBatchRunLaunchOutcome.failedAfterStart( + "Unerwarteter Fehler im Mini-Verarbeitungslauf: " + + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + } + } + + /** + * Executes a document-status reset using a pre-built {@link ApplicationRunContext}. + *

+ * Skips the migrate → load → validate → schema-init sequence. Only the run-lock + * acquisition and the reset use-case execution are performed. + * + * @param ctx the validated application run context; must not be null + * @param fingerprints the set of fingerprints to reset; must not be null + * @return the result of the reset operation; never null + */ + private ResetDocumentStatusResult executeResetWithContext( + ApplicationRunContext ctx, + Set fingerprints) { + try { + UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(ctx.jdbcUrl()); + ProcessingLogger resetLogger = new Log4jProcessingLogger( + DefaultResetDocumentStatusUseCase.class, + resolveAiContentSensitivity(ctx.startConfiguration().logAiSensitive())); + RunLockPort runLockPort = runLockPortFactory.create( + resolveLockFilePath(ctx.startConfiguration())); + try { + runLockPort.acquire(); + } catch (de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException e) { + LOG.warn("GUI-Status-Reset: Laufsperre nicht verfügbar — Reset abgelehnt."); + return allFailures(fingerprints, "Lauf blockiert — Lock nicht verfügbar"); + } + try { + DefaultResetDocumentStatusUseCase useCase = + new DefaultResetDocumentStatusUseCase(unitOfWorkPort, resetLogger); + ResetDocumentStatusResult result = useCase.reset(fingerprints); + LOG.info("GUI-Status-Reset abgeschlossen: {} erfolgreich, {} fehlgeschlagen.", + result.successCount(), result.failureCount()); + return result; + } finally { + try { + runLockPort.release(); + } catch (RuntimeException e) { + LOG.warn("GUI-Status-Reset: Laufsperre konnte nicht freigegeben werden: {}", + e.getMessage(), e); + } + } + } catch (RuntimeException e) { + LOG.error("GUI-Status-Reset: Unerwarteter Fehler: {}", e.getMessage(), e); + return allFailures(fingerprints, "Unerwarteter Fehler: " + + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + } + } + /** * Executes exactly one batch run triggered by the GUI's processing-run tab. *

- * Mirrors the headless bootstrap pipeline: legacy migration, configuration loading and - * validation, SQLite schema initialisation, run-lock acquisition, use-case wiring, and - * execution. Forwards the supplied observer and cancellation token into the wired use - * case so the GUI receives live progress callbacks and can request a soft-stop. + * When a pre-built {@link ApplicationRunContext} is available (built at GUI startup), + * it is used directly and the migrate → load → validate → schema-init sequence is + * skipped. When no context is available (e.g., configuration was invalid at startup), + * the full init sequence runs per call. + *

+ * Forwards the supplied observer and cancellation token into the wired use case so the + * GUI receives live progress callbacks and can request a soft-stop. *

* All known hard startup failures are mapped to * {@link GuiBatchRunLaunchOutcome#rejected(String)}. Unexpected runtime exceptions @@ -1059,6 +1241,13 @@ public class BootstrapRunner { Objects.requireNonNull(progressObserver, "progressObserver must not be null"); Objects.requireNonNull(cancellationToken, "cancellationToken must not be null"); LOG.info("GUI-Verarbeitungslauf: Startanforderung für Konfiguration {}.", configFilePath); + + Optional ctx = guiApplicationRunContext; + if (ctx.isPresent()) { + LOG.debug("GUI-Verarbeitungslauf: Verwende vorbereiteten Anwendungskontext."); + return executeRun(ctx.get(), progressObserver, cancellationToken); + } + try { if (!Files.exists(configFilePath)) { return GuiBatchRunLaunchOutcome.rejected( @@ -1114,6 +1303,23 @@ public class BootstrapRunner { * @param cancellationToken token forwarded into the use case; must not be null * @return the outcome for the mini-run; never null */ + /** + * Executes a targeted mini batch run restricted to the supplied fingerprint set, + * triggered by the GUI's "Erneut verarbeiten" action. + *

+ * When a pre-built {@link ApplicationRunContext} is available (built at GUI startup), + * it is used directly and the migrate → load → validate → schema-init sequence is + * skipped. When no context is available, the full init sequence runs per call. + *

+ * The run lock is acquired to prevent racing with a concurrent headless run. If the lock + * is unavailable, all fingerprints are returned as failures with a German message. + * + * @param configFilePath path to the {@code .properties} configuration; must exist on disk + * @param fingerprintFilter the set of document fingerprints to process; must not be null + * @param progressObserver observer forwarded into the use case; must not be null + * @param cancellationToken token forwarded into the use case; must not be null + * @return the outcome for the mini-run; never null + */ GuiBatchRunLaunchOutcome launchGuiMiniBatchRun( Path configFilePath, Set fingerprintFilter, @@ -1125,6 +1331,13 @@ public class BootstrapRunner { Objects.requireNonNull(cancellationToken, "cancellationToken must not be null"); LOG.info("GUI-Mini-Verarbeitungslauf: Startanforderung für {} Dokument(e), Konfiguration {}.", fingerprintFilter.size(), configFilePath); + + Optional ctx = guiApplicationRunContext; + if (ctx.isPresent()) { + LOG.debug("GUI-Mini-Verarbeitungslauf: Verwende vorbereiteten Anwendungskontext."); + return executeRunWithFilter(ctx.get(), fingerprintFilter, progressObserver, cancellationToken); + } + try { if (!Files.exists(configFilePath)) { return GuiBatchRunLaunchOutcome.rejected( @@ -1135,7 +1348,6 @@ public class BootstrapRunner { initializeSchema(config); RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config)); BatchRunContext runContext = createRunContext(); - // Apply the fingerprint filter to restrict processing to the selected documents BatchRunContext filteredContext = runContext.withFingerprintFilter(fingerprintFilter); BatchRunProcessingUseCase useCase = buildProductionBatchUseCase( config, runLockPort, progressObserver, cancellationToken); @@ -1180,6 +1392,21 @@ public class BootstrapRunner { * @param fingerprints the set of document fingerprints to reset; must not be null * @return the result of the reset operation; never null */ + /** + * Resets the processing status of the specified documents by deleting all persistence + * data for their fingerprints, triggered by the GUI's "Status zurücksetzen" action. + *

+ * When a pre-built {@link ApplicationRunContext} is available (built at GUI startup), + * it is used directly and the migrate → load → validate → schema-init sequence is + * skipped. When no context is available, the full init sequence runs per call. + *

+ * The run lock is acquired to avoid racing with a concurrent headless run. If the lock + * is unavailable, all fingerprints are returned as failures with a German message. + * + * @param configFilePath path to the {@code .properties} configuration; must exist on disk + * @param fingerprints the set of document fingerprints to reset; must not be null + * @return the result of the reset operation; never null + */ ResetDocumentStatusResult resetDocumentStatusForGui( Path configFilePath, Set fingerprints) { @@ -1188,6 +1415,12 @@ public class BootstrapRunner { LOG.info("GUI-Status-Reset: {} Dokument(e) zurücksetzen, Konfiguration {}.", fingerprints.size(), configFilePath); + Optional ctx = guiApplicationRunContext; + if (ctx.isPresent()) { + LOG.debug("GUI-Status-Reset: Verwende vorbereiteten Anwendungskontext."); + return executeResetWithContext(ctx.get(), fingerprints); + } + if (!Files.exists(configFilePath)) { String msg = "Konfigurationsdatei nicht gefunden: " + configFilePath; LOG.error("GUI-Status-Reset: {}", msg); diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java index c51e965..c4a6e74 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java @@ -388,11 +388,21 @@ class BootstrapRunnerConfigPathSemanticsTest { /** * Creates a {@link BootstrapRunner} wired with a controllable GUI adapter factory * and stub factories for all headless-path dependencies. + *

+ * The config port factory throws {@link de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException} + * so that {@code initializeApplicationRunContext()} can handle it gracefully (storing + * the error in {@code applicationContextError}) without crashing the GUI launch sequence. + * Tests that verify the absence of a startup notice or the presence of a loaded editor + * state are not affected by the context-init failure. + *

+ * Run-lock and schema-init factories still throw hard errors because they must never be + * reached from the GUI path in test scenarios without a valid application run context. */ private BootstrapRunner runnerWithGuiFactory(BootstrapRunner.GuiAdapterFactory guiAdapterFactory) { return new BootstrapRunner( path -> { /* no-op migration */ }, - configPath -> { throw new AssertionError("ConfigurationPort must not be called in GUI mode"); }, + configPath -> { throw new de.gecheckt.pdf.umbenenner.adapter.out.configuration + .ConfigurationLoadingException("Stub: no config port in GUI test context"); }, lockFile -> { throw new AssertionError("RunLockPort must not be called in GUI mode"); }, () -> { throw new AssertionError("Validator must not be called in GUI mode"); }, jdbcUrl -> { throw new AssertionError("SchemaInitPort must not be called in GUI mode"); },