M10 vollständig abgeschlossen (AP-004 bis AP-007)

- AP-004: Speichern und Speichern unter mit .bak-Rotation,
  normalisierte .properties-Ausgabe, API-Key-Erhaltung bei leerem Feld
- AP-005: Dirty-State aus Editorzustand, Fenstertitel- und
  Header-Marker, Schutzdialog (Speichern/Verwerfen/Abbrechen)
  vor Neu/Öffnen/Schließen inkl. Close-Request-Handler
- AP-006: Vollständige Editoroberfläche mit allen Konfigurationswerten,
  native Pfad-Picker für Quell-/Zielordner, SQLite- und Prompt-Datei,
  Files.exists-Pfadprüfung auf Worker-Thread verlagert
- AP-007: Integrations- und Regressionstests für alle zentralen
  Bedienpfade, Writer-Threading-Contract dokumentiert und getestet

Hexagonale Architektur, Threadingmodell und Naming-Regel durchgehend
eingehalten. Keine Vorgriffe auf M11/M12.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:51:13 +02:00
parent 6d4654f482
commit bbb5c4da3a
22 changed files with 5221 additions and 37 deletions
@@ -0,0 +1,34 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
/**
* Writes a normalized {@code .properties} configuration file from the current editor values.
* <p>
* The interface allows Bootstrap to provide the concrete file-writing, backup and
* normalization logic while the GUI only deals with editor values and target paths.
* Implementations must follow the backup schema defined for this application:
* {@code <filename>.bak}, and on collision {@code <filename>.bak.1}, {@code .bak.2}, ...
* Existing backups are never overwritten.
*/
@FunctionalInterface
public interface GuiConfigurationFileWriter {
/**
* Writes the given configuration values to the specified target path as a normalized
* {@code .properties} file.
* <p>
* When {@code targetPath} already exists on disk, the implementation must create a
* {@code .bak} backup of the existing file before overwriting it. The caller is
* responsible for obtaining user confirmation before invoking this method.
*
* @param values the current editor values to serialize; must not be {@code null}
* @param targetPath the target file path to write to; must not be {@code null}
* @return the result of the write operation, including any API-key preservation note;
* never {@code null}
* @throws GuiConfigurationWriteException if the file cannot be written
*/
GuiConfigurationSaveResult write(GuiConfigurationValues values, Path targetPath);
}
@@ -0,0 +1,65 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.nio.file.Path;
import java.util.Objects;
/**
* Carries the outcome of a successful configuration file write operation.
* <p>
* The result separates the written file path from supplementary observations such as
* API-key preservation events. This allows the GUI to update its header and editor
* state without inspecting the written file again, and to forward the preservation
* flag to later warning display logic without mixing that concern into the write
* implementation itself.
*
* @param savedPath the path to which the file was written; never {@code null}
* @param apiKeyPreservedForProvider identifier of the provider whose API key was silently
* preserved because the GUI field was left empty while
* the existing property value was non-empty; {@code null}
* when no preservation occurred
*/
public record GuiConfigurationSaveResult(Path savedPath, String apiKeyPreservedForProvider) {
/**
* Creates a save result.
*
* @param savedPath the path that was written; must not be {@code null}
* @param apiKeyPreservedForProvider provider identifier when key was preserved; may be {@code null}
*/
public GuiConfigurationSaveResult {
Objects.requireNonNull(savedPath, "savedPath must not be null");
}
/**
* Creates a save result with no API-key preservation event.
*
* @param savedPath the path that was written; must not be {@code null}
* @return a result without an API-key preservation note
*/
public static GuiConfigurationSaveResult saved(Path savedPath) {
return new GuiConfigurationSaveResult(savedPath, null);
}
/**
* Creates a save result that records an API-key preservation event.
*
* @param savedPath the path that was written; must not be {@code null}
* @param providerIdentifier the provider for which the key was preserved;
* must not be {@code null}
* @return a result carrying the preservation note for later display
*/
public static GuiConfigurationSaveResult savedWithPreservedKey(Path savedPath,
String providerIdentifier) {
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
return new GuiConfigurationSaveResult(savedPath, providerIdentifier);
}
/**
* Returns whether an API-key preservation event occurred during this write operation.
*
* @return {@code true} when at least one provider API key was silently preserved
*/
public boolean hasApiKeyPreservationNote() {
return apiKeyPreservedForProvider != null;
}
}
@@ -0,0 +1,29 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
/**
* Thrown when a configuration file cannot be written by the GUI file writer.
* <p>
* This exception wraps low-level I/O failures so that the GUI layer does not have
* to handle raw {@link java.io.IOException} instances directly.
*/
public class GuiConfigurationWriteException extends RuntimeException {
/**
* Creates an exception with the given message.
*
* @param message the error description; must not be {@code null}
*/
public GuiConfigurationWriteException(String message) {
super(message);
}
/**
* Creates an exception with the given message and cause.
*
* @param message the error description; must not be {@code null}
* @param cause the underlying cause; may be {@code null}
*/
public GuiConfigurationWriteException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -9,13 +9,14 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorSt
/**
* Immutable startup data for the GUI adapter.
* <p>
* Carries the initial editor state, the optional startup notice and the file-loading callback
* that the workspace uses for native open actions.
* Carries the initial editor state, the optional startup notice, the file-loading callback
* and the file-writing callback that the workspace uses for native save actions.
*/
public record GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader) {
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter) {
/**
* Creates a startup context.
@@ -23,16 +24,19 @@ public record GuiStartupContext(
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
startupNotice = startupNotice == null ? Optional.empty() : startupNotice;
configurationFileLoader = Objects.requireNonNull(configurationFileLoader,
"configurationFileLoader must not be null");
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
"configurationFileWriter must not be null");
}
/**
* Creates a blank startup context with no loader side effects.
* Creates a blank startup context with no loader or writer side effects.
*
* @param startupNotice optional startup notice; {@code null} becomes empty
* @return a startup context for the unloaded editor start
@@ -41,6 +45,7 @@ public record GuiStartupContext(
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
startupNotice,
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState());
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
(values, path) -> GuiConfigurationSaveResult.saved(path));
}
}
@@ -0,0 +1,87 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.function.Function;
/**
* Mediates the three-way protection dialog before any action that would discard unsaved changes.
* <p>
* The guard asks the user whether to save, discard or cancel the requested action.
* The dialog interaction is injected via a {@link Function} so the guard can be tested
* without a running JavaFX scene by substituting the real dialog with a stub.
*
* <p>Usage:
* <ol>
* <li>Obtain an instance from the workspace.</li>
* <li>Call {@link #askAndProceed(String, Runnable, Runnable)} with the intended follow-up action.</li>
* <li>The guard shows the dialog when the editor is dirty and runs the follow-up only when
* it is safe to proceed.</li>
* </ol>
*/
public final class GuiUnsavedChangesGuard {
/**
* The possible responses the user can give to the protection dialog.
*/
public enum Choice {
/** Save the current changes and then continue with the requested action. */
SAVE,
/** Discard all unsaved changes and continue with the requested action. */
DISCARD,
/** Cancel the requested action; no state change is performed. */
CANCEL
}
/**
* Supplies the user's choice for a given trigger label.
* <p>
* In production the function shows a modal dialog; in tests it can be replaced with a stub.
*/
private Function<String, Choice> dialogSupplier;
/**
* Creates a guard that delegates the dialog interaction to the supplied function.
*
* @param dialogSupplier function that maps a trigger label to the user's choice; must not be {@code null}
*/
public GuiUnsavedChangesGuard(Function<String, Choice> dialogSupplier) {
this.dialogSupplier = dialogSupplier;
}
/**
* Replaces the dialog supplier at runtime.
* <p>
* Package-private so tests can inject stubs without exposing setter to external callers.
*
* @param dialogSupplier the replacement function; must not be {@code null}
*/
void setDialogSupplier(Function<String, Choice> dialogSupplier) {
this.dialogSupplier = dialogSupplier;
}
/**
* Asks the user how to handle unsaved changes before the named action and invokes the
* appropriate callback.
*
* <ul>
* <li>{@link Choice#SAVE} → {@code onSave} is called; the caller must invoke
* {@code onProceed} itself after a successful save.</li>
* <li>{@link Choice#DISCARD} → {@code onProceed} is called immediately.</li>
* <li>{@link Choice#CANCEL} → neither callback is called.</li>
* </ul>
*
* @param triggerLabel a short label identifying the triggering action (e.g. "Neu", "Öffnen");
* used to give the dialog context; must not be {@code null}
* @param onProceed action to run when the user chose discard; must not be {@code null}
* @param onSave action to run when the user chose save; must not be {@code null}
*/
public void askAndProceed(String triggerLabel, Runnable onProceed, Runnable onSave) {
Choice choice = dialogSupplier.apply(triggerLabel);
switch (choice) {
case SAVE -> onSave.run();
case DISCARD -> onProceed.run();
case CANCEL -> {
// No action caller keeps the current state.
}
}
}
}
@@ -0,0 +1,68 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiChangeState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
/**
* Formats the window title string for the PDF-Umbenenner GUI editor.
* <p>
* The title reflects the current editor state: whether a file is loaded and whether the
* editor contains unsaved changes. The application name and the separator are kept in
* one place so every part of the GUI uses the same formatting convention.
*
* <ul>
* <li>Clean state with loaded file: {@code "PDF-Umbenenner — <filename>"}</li>
* <li>Clean state without file (new configuration): {@code "PDF-Umbenenner — Neue Konfiguration"}</li>
* <li>Dirty state: the same formats with a leading {@code "* "} prefix</li>
* </ul>
*/
public final class GuiWindowTitleFormatter {
/** The application name shown in every window title variant. */
static final String APPLICATION_NAME = "PDF-Umbenenner";
/** Separator placed between the application name and the context section. */
static final String SEPARATOR = " \u2014 ";
/** Prefix added to the title when the editor contains unsaved changes. */
static final String DIRTY_PREFIX = "* ";
/** Context label used when no file has been loaded yet. */
static final String NEW_CONFIGURATION_LABEL = "Neue Konfiguration";
private GuiWindowTitleFormatter() {
// Utility class.
}
/**
* Formats the window title for the given editor state.
*
* @param editorState the current editor state; must not be {@code null}
* @return the formatted window title string; never {@code null}
*/
public static String format(GuiConfigurationEditorState editorState) {
String contextPart = buildContextPart(editorState);
String base = APPLICATION_NAME + SEPARATOR + contextPart;
if (editorState.changeState() == GuiChangeState.DIRTY) {
return DIRTY_PREFIX + base;
}
return base;
}
/**
* Returns the context portion of the title (the part after the separator).
*
* @param editorState the current editor state; must not be {@code null}
* @return the context string; never {@code null}
*/
private static String buildContextPart(GuiConfigurationEditorState editorState) {
if (editorState.isNewConfiguration()) {
return NEW_CONFIGURATION_LABEL;
}
String fullPath = editorState.loadedFileSnapshot()
.map(snapshot -> snapshot.filePath().getFileName())
.map(Object::toString)
.orElse(NEW_CONFIGURATION_LABEL);
return fullPath;
}
}
@@ -13,11 +13,15 @@ import org.apache.logging.log4j.Logger;
* The application starts the editor shell in a clean, unloaded state unless Bootstrap
* has provided a preloaded startup context. The visible editor surface is delegated to
* {@link GuiConfigurationEditorWorkspace}.
*
* <p>The window title is kept in sync with the workspace's dirty state via the
* {@code titleUpdateListener} hook. The close-request handler is installed through
* {@link GuiConfigurationEditorWorkspace#installCloseRequestHandler(Stage)} so that
* unsaved changes are protected when the user tries to close the window.
*/
public class PdfUmbenennerGuiApplication extends Application {
private static final Logger LOG = LogManager.getLogger(PdfUmbenennerGuiApplication.class);
private static final String WINDOW_TITLE = "PDF-Umbenenner";
private static final double DEFAULT_WIDTH = 1100;
private static final double DEFAULT_HEIGHT = 800;
@@ -30,6 +34,10 @@ public class PdfUmbenennerGuiApplication extends Application {
/**
* Initializes and shows the primary stage.
* <p>
* Wires the workspace title-update listener to the stage title so any dirty-state change
* causes an immediate window-title refresh. Also installs the close-request handler that
* guards unsaved changes before the window is closed.
*
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
*/
@@ -39,11 +47,17 @@ public class PdfUmbenennerGuiApplication extends Application {
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
primaryStage.setTitle(WINDOW_TITLE);
// Wire the title-update listener so the stage title stays in sync with the dirty state.
workspace.titleUpdateListener = primaryStage::setTitle;
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
primaryStage.setScene(scene);
primaryStage.setOnCloseRequest(event -> LOG.info("GUI: Fenster wird vom Benutzer geschlossen."));
// Install the close-request handler that protects unsaved changes.
workspace.installCloseRequestHandler(primaryStage);
primaryStage.show();
LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
@@ -0,0 +1,120 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.LinkedHashMap;
import java.util.Map;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
/**
* Merges the current editor API-key values against the baseline values before a file
* is written to disk.
* <p>
* The merge rule is:
* <ul>
* <li>When a provider's API-key field is non-empty in the current editor values, the
* current value is kept unchanged.</li>
* <li>When a provider's API-key field is empty in the current editor values but the
* corresponding baseline field holds a non-empty value, the baseline value is
* carried into the merged result so the key is not silently deleted from the
* written file.</li>
* <li>When both the current and the baseline value are empty, no preservation occurs
* and the merged result also contains an empty value.</li>
* </ul>
*
* <p>The result indicates which provider (if any) triggered a preservation event so the
* GUI can display a warning via later validation layers without coupling the write path
* to the warning display mechanism.
*/
public final class GuiApiKeyMerger {
private GuiApiKeyMerger() {
// Utility class.
}
/**
* Merges the API-key values from the given editor state and returns both the merged
* values and the first provider identifier for which a key was silently preserved.
*
* @param state the current editor state; must not be {@code null}
* @return the merge result; never {@code null}
*/
public static MergeResult merge(GuiConfigurationEditorState state) {
return merge(state.values(), state.baselineValues());
}
/**
* Merges the API-key values from the given current and baseline configuration values.
*
* @param current the current editor values; must not be {@code null}
* @param baseline the baseline values to compare against; must not be {@code null}
* @return the merge result; never {@code null}
*/
public static MergeResult merge(GuiConfigurationValues current, GuiConfigurationValues baseline) {
Map<AiProviderFamily, GuiProviderConfigurationState> merged = new LinkedHashMap<>(
current.providerConfigurations());
String preservedProvider = null;
for (AiProviderFamily family : AiProviderFamily.values()) {
GuiProviderConfigurationState currentProvider = current.providerConfiguration(family);
if (currentProvider == null) {
continue;
}
String editorKey = currentProvider.apiKey().propertyValue();
if (!editorKey.isBlank()) {
continue;
}
GuiProviderConfigurationState baselineProvider = baseline.providerConfiguration(family);
if (baselineProvider == null) {
continue;
}
String baselineKey = baselineProvider.apiKey().propertyValue();
if (baselineKey != null && !baselineKey.isBlank()) {
merged.put(family, new GuiProviderConfigurationState(
currentProvider.baseUrl(),
currentProvider.model(),
currentProvider.timeoutSeconds(),
GuiProviderApiKeyState.unresolved(baselineKey)));
if (preservedProvider == null) {
preservedProvider = family.getIdentifier();
}
}
}
GuiConfigurationValues mergedValues = new GuiConfigurationValues(
current.sourceFolder(),
current.targetFolder(),
current.sqliteFile(),
current.promptTemplateFile(),
current.runtimeLockFile(),
current.logDirectory(),
current.logLevel(),
current.maxRetriesTransient(),
current.maxPages(),
current.maxTextCharacters(),
current.logAiSensitive(),
current.activeProviderFamily(),
merged);
return new MergeResult(mergedValues, preservedProvider);
}
/**
* Result of an API-key merge operation.
*
* @param values the merged configuration values; never {@code null}
* @param preservedProviderIdentifier provider identifier when a key was preserved from
* the baseline; {@code null} when no preservation occurred
*/
public record MergeResult(GuiConfigurationValues values, String preservedProviderIdentifier) {
/**
* Returns whether at least one provider API key was silently preserved.
*
* @return {@code true} when a preservation event occurred
*/
public boolean hasPreservationNote() {
return preservedProviderIdentifier != null;
}
}
}
@@ -101,6 +101,168 @@ public record GuiConfigurationValues(
logAiSensitive, providerFamily, providerConfigurations);
}
/**
* Returns a copy with a different source-folder path.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested source folder
*/
public GuiConfigurationValues withSourceFolder(String value) {
return new GuiConfigurationValues(value, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different target-folder path.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested target folder
*/
public GuiConfigurationValues withTargetFolder(String value) {
return new GuiConfigurationValues(sourceFolder, value, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different SQLite file path.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested SQLite file path
*/
public GuiConfigurationValues withSqliteFile(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, value, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different prompt-template file path.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested prompt-template file path
*/
public GuiConfigurationValues withPromptTemplateFile(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, value,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different runtime lock file path.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested runtime lock file path
*/
public GuiConfigurationValues withRuntimeLockFile(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
value, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different log directory path.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested log directory
*/
public GuiConfigurationValues withLogDirectory(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, value, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different log level.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested log level
*/
public GuiConfigurationValues withLogLevel(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, value, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different transient-retry limit.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested retry limit
*/
public GuiConfigurationValues withMaxRetriesTransient(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, value, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different page limit.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested page limit
*/
public GuiConfigurationValues withMaxPages(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, value, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different text-character limit.
*
* @param value new value; {@code null} becomes an empty string
* @return a new configuration values object with the requested character limit
*/
public GuiConfigurationValues withMaxTextCharacters(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, value,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different {@code log.ai.sensitive} value.
*
* @param value new raw boolean value; {@code null} becomes an empty string
* @return a new configuration values object with the requested sensitive-log setting
*/
public GuiConfigurationValues withLogAiSensitive(String value) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
value, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different provider-configurations map.
*
* @param providerConfigurations new provider map; must not be {@code null}
* @return a new configuration values object with the requested provider configurations
*/
public GuiConfigurationValues withProviderConfigurations(
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
logAiSensitive, activeProviderFamily, providerConfigurations);
}
/**
* Returns a copy with a different configuration for one provider family.
* <p>
* All other provider-family entries are preserved unchanged.
*
* @param family the provider family to update; must not be {@code null}
* @param state the new provider configuration state; must not be {@code null}
* @return a new configuration values object with the updated provider configuration
*/
public GuiConfigurationValues withProviderConfiguration(AiProviderFamily family,
GuiProviderConfigurationState state) {
Map<AiProviderFamily, GuiProviderConfigurationState> updated =
new java.util.LinkedHashMap<>(providerConfigurations);
updated.put(family, state);
return withProviderConfigurations(updated);
}
private static String normalizeText(String value) {
return value == null ? "" : value;
}