diff --git a/.gitignore b/.gitignore index 6d092af..04eab71 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ replay_pid* /review-input.zip /run-milestone.ps1 /run-v11.ps1 +.m2repo diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiChangeState.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiChangeState.java new file mode 100644 index 0000000..d1e2529 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiChangeState.java @@ -0,0 +1,25 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +/** + * Derived change-state view for the editor content. + *

+ * The value is computed from the comparison between the baseline values and the current + * editor values. + */ +public enum GuiChangeState { + + /** The current editor state matches its baseline. */ + CLEAN, + + /** The current editor state has diverged from its baseline. */ + DIRTY; + + /** + * Returns whether this state represents unsaved changes. + * + * @return {@code true} when the editor is dirty, otherwise {@code false} + */ + public boolean isDirty() { + return this == DIRTY; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorState.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorState.java new file mode 100644 index 0000000..72d0657 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorState.java @@ -0,0 +1,121 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import java.util.Objects; +import java.util.Optional; + +/** + * Root editor state for the GUI configuration editor. + *

+ * The state keeps the loaded file snapshot, the baseline values and the current editable values + * separate so later GUI layers can compare the current editor content against the original + * representation without relying on a manually maintained dirty flag. + * + * @param loadedFileSnapshot loaded file representation, or empty when the editor was created + * from the standard template + * @param baselineValues values representing the baseline the editor compares against + * @param values current editable configuration values + */ +public record GuiConfigurationEditorState( + Optional loadedFileSnapshot, + GuiConfigurationValues baselineValues, + GuiConfigurationValues values) { + + /** + * Creates a new editor state. + * + * @param loadedFileSnapshot loaded file representation; {@code null} becomes empty + * @param baselineValues baseline values; must not be {@code null} + * @param values current editable configuration values; must not be {@code null} + */ + public GuiConfigurationEditorState { + loadedFileSnapshot = loadedFileSnapshot == null ? Optional.empty() : loadedFileSnapshot; + baselineValues = Objects.requireNonNull(baselineValues, "baselineValues must not be null"); + values = Objects.requireNonNull(values, "values must not be null"); + } + + /** + * Returns whether the editor currently contains unsaved changes. + * + * @return {@code true} when the current values differ from the baseline, otherwise {@code false} + */ + public boolean isDirty() { + return !baselineValues.equals(values); + } + + /** + * Returns the derived change state for the current editor content. + * + * @return {@link GuiChangeState#DIRTY} when the editor is dirty, otherwise {@link GuiChangeState#CLEAN} + */ + public GuiChangeState changeState() { + return isDirty() ? GuiChangeState.DIRTY : GuiChangeState.CLEAN; + } + + /** + * Returns whether the editor currently has a loaded file snapshot. + * + * @return {@code true} when the editor was loaded from disk, otherwise {@code false} + */ + public boolean hasLoadedFileSnapshot() { + return loadedFileSnapshot.isPresent(); + } + + /** + * Returns whether the editor represents a newly created configuration. + * + * @return {@code true} when no file snapshot is loaded, otherwise {@code false} + */ + public boolean isNewConfiguration() { + return loadedFileSnapshot.isEmpty(); + } + + /** + * Returns a copy with different current editable values. + * + * @param values the new editable values; must not be {@code null} + * @return a new editor state containing the supplied values + */ + public GuiConfigurationEditorState withValues(GuiConfigurationValues values) { + return new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, values); + } + + /** + * Returns a copy with a different baseline. + * + * @param baselineValues the new baseline values; must not be {@code null} + * @return a new editor state using the supplied baseline + */ + public GuiConfigurationEditorState withBaselineValues(GuiConfigurationValues baselineValues) { + return new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, values); + } + + /** + * Returns a copy with a loaded file snapshot. + * + * @param snapshot the loaded file snapshot; must not be {@code null} + * @return a new editor state linked to the supplied snapshot + */ + public GuiConfigurationEditorState withLoadedFileSnapshot(GuiConfigurationFileSnapshot snapshot) { + return new GuiConfigurationEditorState(Optional.of(snapshot), baselineValues, values); + } + + /** + * Returns a copy without any loaded file snapshot. + * + * @return a new editor state without a loaded file snapshot + */ + public GuiConfigurationEditorState withoutLoadedFileSnapshot() { + return new GuiConfigurationEditorState(Optional.empty(), baselineValues, values); + } + + /** + * Returns a clean copy of this editor state by resetting the current values to the baseline. + * + * @return a clean editor state + */ + public GuiConfigurationEditorState markClean() { + return baselineValues.equals(values) + ? this + : new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, baselineValues); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationFileSnapshot.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationFileSnapshot.java new file mode 100644 index 0000000..d73ac6c --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationFileSnapshot.java @@ -0,0 +1,86 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.Properties; + +/** + * Snapshot of a configuration file loaded from disk. + *

+ * This type is intentionally separate from the editable editor state. It represents the file + * that was loaded, not the mutable values currently being edited. + */ +public final class GuiConfigurationFileSnapshot { + + private final Path filePath; + private final Properties properties; + + /** + * Creates a snapshot for the given file and properties. + * + * @param filePath path of the loaded file; must not be {@code null} + * @param properties loaded properties; must not be {@code null} + */ + public GuiConfigurationFileSnapshot(Path filePath, Properties properties) { + this.filePath = Objects.requireNonNull(filePath, "filePath must not be null"); + this.properties = copyOf(properties); + } + + /** + * Returns the file path of the loaded configuration. + * + * @return the snapshot file path; never {@code null} + */ + public Path filePath() { + return filePath; + } + + /** + * Returns a defensive copy of the loaded properties. + * + * @return a copy of the loaded properties; never {@code null} + */ + public Properties properties() { + return copyOf(properties); + } + + /** + * Returns whether this snapshot was loaded from the given path. + * + * @param candidatePath the path to compare; may be {@code null} + * @return {@code true} when the paths match, otherwise {@code false} + */ + public boolean hasFilePath(Path candidatePath) { + return filePath.equals(candidatePath); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof GuiConfigurationFileSnapshot that)) { + return false; + } + return filePath.equals(that.filePath) && properties.equals(that.properties); + } + + @Override + public int hashCode() { + return Objects.hash(filePath, properties); + } + + @Override + public String toString() { + return "GuiConfigurationFileSnapshot{" + + "filePath=" + filePath + + ", properties=" + properties + + '}'; + } + + private static Properties copyOf(Properties source) { + Properties copy = new Properties(); + copy.putAll(Objects.requireNonNull(source, "properties must not be null")); + return copy; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java new file mode 100644 index 0000000..8a711d4 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java @@ -0,0 +1,83 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; + +/** + * Creates the standard GUI configuration template. + *

+ * The template contains both known provider blocks, a complete set of general configuration + * values and a clean editor state that can be used for a brand-new configuration. + */ +public final class GuiConfigurationTemplateFactory { + + private static final String SOURCE_FOLDER = "./work/local/source"; + private static final String TARGET_FOLDER = "./work/local/target"; + private static final String SQLITE_FILE = "./work/local/pdf-umbenenner.db"; + private static final String PROMPT_TEMPLATE_FILE = "./config/prompts/template.txt"; + private static final String RUNTIME_LOCK_FILE = "./work/local/pdf-umbenenner.lock"; + private static final String LOG_DIRECTORY = "./work/local/logs"; + private static final String LOG_LEVEL = "INFO"; + private static final String MAX_RETRIES_TRANSIENT = "3"; + private static final String MAX_PAGES = "10"; + private static final String MAX_TEXT_CHARACTERS = "5000"; + + private static final String OPENAI_BASE_URL = "https://api.openai.com/v1"; + private static final String OPENAI_MODEL = "gpt-4o-mini"; + private static final String OPENAI_TIMEOUT_SECONDS = "30"; + + private static final String CLAUDE_BASE_URL = "https://api.anthropic.com"; + private static final String CLAUDE_MODEL = "claude-3-5-sonnet-20241022"; + private static final String CLAUDE_TIMEOUT_SECONDS = "60"; + + private GuiConfigurationTemplateFactory() { + // Utility class. + } + + /** + * Creates a brand-new clean editor state containing the standard configuration template. + * + * @return a clean editor state with no loaded file snapshot + */ + public static GuiConfigurationEditorState createStandardTemplate() { + GuiConfigurationValues standardValues = createStandardValues(); + return new GuiConfigurationEditorState(Optional.empty(), standardValues, standardValues); + } + + /** + * Creates the editable values for the standard configuration template. + * + * @return the standard editable configuration values + */ + public static GuiConfigurationValues createStandardValues() { + Map providerConfigurations = new LinkedHashMap<>(); + providerConfigurations.put(AiProviderFamily.CLAUDE, new GuiProviderConfigurationState( + CLAUDE_BASE_URL, + CLAUDE_MODEL, + CLAUDE_TIMEOUT_SECONDS, + GuiProviderApiKeyState.unresolved())); + providerConfigurations.put(AiProviderFamily.OPENAI_COMPATIBLE, new GuiProviderConfigurationState( + OPENAI_BASE_URL, + OPENAI_MODEL, + OPENAI_TIMEOUT_SECONDS, + GuiProviderApiKeyState.unresolved())); + + return new GuiConfigurationValues( + SOURCE_FOLDER, + TARGET_FOLDER, + SQLITE_FILE, + PROMPT_TEMPLATE_FILE, + RUNTIME_LOCK_FILE, + LOG_DIRECTORY, + LOG_LEVEL, + MAX_RETRIES_TRANSIENT, + MAX_PAGES, + MAX_TEXT_CHARACTERS, + Boolean.toString(false), + AiProviderFamily.CLAUDE.getIdentifier(), + providerConfigurations); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValues.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValues.java new file mode 100644 index 0000000..8afeb00 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValues.java @@ -0,0 +1,107 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; + +/** + * Editable GUI values for the complete configuration document. + *

+ * This object combines all known general configuration values and the provider-specific blocks + * in a single model that can be rendered and edited by the GUI. + * + * @param sourceFolder source-folder path as editable text + * @param targetFolder target-folder path as editable text + * @param sqliteFile SQLite file path as editable text + * @param promptTemplateFile prompt-template path as editable text + * @param runtimeLockFile optional runtime lock file path as editable text + * @param logDirectory optional log directory as editable text + * @param logLevel log level as editable text + * @param maxRetriesTransient transient retry limit as editable text + * @param maxPages page limit as editable text + * @param maxTextCharacters text limit as editable text + * @param logAiSensitive raw value of {@code log.ai.sensitive} as editable text + * @param activeProviderFamily raw value of {@code ai.provider.active} as editable text + * @param providerConfigurations provider-specific editor state keyed by provider family + */ +public record GuiConfigurationValues( + String sourceFolder, + String targetFolder, + String sqliteFile, + String promptTemplateFile, + String runtimeLockFile, + String logDirectory, + String logLevel, + String maxRetriesTransient, + String maxPages, + String maxTextCharacters, + String logAiSensitive, + String activeProviderFamily, + Map providerConfigurations) { + + /** + * Creates a new editable configuration state. + * + * @param sourceFolder source-folder path; {@code null} becomes an empty string + * @param targetFolder target-folder path; {@code null} becomes an empty string + * @param sqliteFile SQLite file path; {@code null} becomes an empty string + * @param promptTemplateFile prompt-template path; {@code null} becomes an empty string + * @param runtimeLockFile runtime lock file path; {@code null} becomes an empty string + * @param logDirectory log directory; {@code null} becomes an empty string + * @param logLevel log level; {@code null} becomes an empty string + * @param maxRetriesTransient transient retry limit; {@code null} becomes an empty string + * @param maxPages page limit; {@code null} becomes an empty string + * @param maxTextCharacters text limit; {@code null} becomes an empty string + * @param logAiSensitive raw {@code log.ai.sensitive} value; {@code null} becomes an empty string + * @param activeProviderFamily raw {@code ai.provider.active} value; {@code null} becomes an empty string + * @param providerConfigurations provider-specific state map; must not be {@code null} + */ + public GuiConfigurationValues { + sourceFolder = normalizeText(sourceFolder); + targetFolder = normalizeText(targetFolder); + sqliteFile = normalizeText(sqliteFile); + promptTemplateFile = normalizeText(promptTemplateFile); + runtimeLockFile = normalizeText(runtimeLockFile); + logDirectory = normalizeText(logDirectory); + logLevel = normalizeText(logLevel); + maxRetriesTransient = normalizeText(maxRetriesTransient); + maxPages = normalizeText(maxPages); + maxTextCharacters = normalizeText(maxTextCharacters); + logAiSensitive = normalizeText(logAiSensitive); + activeProviderFamily = normalizeText(activeProviderFamily); + + LinkedHashMap copy = new LinkedHashMap<>(); + Objects.requireNonNull(providerConfigurations, "providerConfigurations must not be null") + .forEach(copy::put); + providerConfigurations = Collections.unmodifiableMap(copy); + } + + /** + * Returns the editable state for the requested provider family. + * + * @param providerFamily the provider family to look up; must not be {@code null} + * @return the matching provider state, or {@code null} when not present + */ + public GuiProviderConfigurationState providerConfiguration(AiProviderFamily providerFamily) { + return providerConfigurations.get(providerFamily); + } + + /** + * Returns a copy with a different active provider-family identifier. + * + * @param providerFamily raw provider-family identifier; {@code null} becomes an empty string + * @return a new configuration values object with the requested active provider value + */ + public GuiConfigurationValues withActiveProviderFamily(String providerFamily) { + return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile, + runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters, + logAiSensitive, providerFamily, providerConfigurations); + } + + private static String normalizeText(String value) { + return value == null ? "" : value; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiProviderApiKeyState.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiProviderApiKeyState.java new file mode 100644 index 0000000..aabbb7a --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiProviderApiKeyState.java @@ -0,0 +1,67 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import java.util.Objects; + +/** + * Editable state for a provider-specific API key. + *

+ * This value object keeps the editable property value separate from the later displayable + * origin of the effective key value. The editor can therefore preserve the property text + * while later layers determine whether the effective value comes from the file, an + * environment variable, a compatibility fallback, or a technical default. + * + * @param propertyValue editable API-key property value as entered or loaded from the file + * @param effectiveValueOrigin provenance of the effective value shown or inferred later + * @param effectiveValueDescription human-readable provenance description for later display + */ +public record GuiProviderApiKeyState( + String propertyValue, + GuiValueOrigin effectiveValueOrigin, + String effectiveValueDescription) { + + /** + * Creates a key state with a blank property value and unresolved provenance. + * + * @return a new unresolved API-key state + */ + public static GuiProviderApiKeyState unresolved() { + return new GuiProviderApiKeyState("", GuiValueOrigin.UNKNOWN, "nicht aufgeloest"); + } + + /** + * Creates a key state with the supplied property value and unresolved provenance. + * + * @param propertyValue editable property value; may be {@code null} + * @return a new unresolved API-key state + */ + public static GuiProviderApiKeyState unresolved(String propertyValue) { + return new GuiProviderApiKeyState(propertyValue, GuiValueOrigin.UNKNOWN, "nicht aufgeloest"); + } + + /** + * Creates a key state with an explicit effective-value provenance. + * + * @param propertyValue editable property value; may be {@code null} + * @param effectiveValueOrigin origin of the effective value; must not be {@code null} + * @param effectiveValueDescription human-readable provenance description; may be {@code null} + * @return a new API-key state with the requested provenance + */ + public static GuiProviderApiKeyState resolved(String propertyValue, + GuiValueOrigin effectiveValueOrigin, + String effectiveValueDescription) { + return new GuiProviderApiKeyState(propertyValue, effectiveValueOrigin, effectiveValueDescription); + } + + /** + * Creates a new key state. + * + * @param propertyValue editable property value; {@code null} is converted to an empty string + * @param effectiveValueOrigin origin of the effective value; {@code null} becomes {@link GuiValueOrigin#UNKNOWN} + * @param effectiveValueDescription human-readable provenance description; {@code null} becomes an empty string + */ + public GuiProviderApiKeyState { + propertyValue = propertyValue == null ? "" : propertyValue; + effectiveValueOrigin = Objects.requireNonNullElse(effectiveValueOrigin, GuiValueOrigin.UNKNOWN); + effectiveValueDescription = effectiveValueDescription == null ? "" : effectiveValueDescription; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiProviderConfigurationState.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiProviderConfigurationState.java new file mode 100644 index 0000000..39ef352 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiProviderConfigurationState.java @@ -0,0 +1,45 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import java.util.Objects; + +/** + * Editable GUI state for one provider-family configuration block. + *

+ * The fields are stored as editor-friendly strings so later GUI layers can bind them directly + * to text-based controls without prematurely parsing or validating them. + * + * @param baseUrl provider endpoint base URL as editable text + * @param model model identifier as editable text + * @param timeoutSeconds timeout value as editable text + * @param apiKey API-key state for the provider + */ +public record GuiProviderConfigurationState( + String baseUrl, + String model, + String timeoutSeconds, + GuiProviderApiKeyState apiKey) { + + /** + * Creates a provider configuration state with all text fields blank and the API key unresolved. + * + * @return a blank provider configuration state + */ + public static GuiProviderConfigurationState blank() { + return new GuiProviderConfigurationState("", "", "", GuiProviderApiKeyState.unresolved()); + } + + /** + * Creates a new provider configuration state. + * + * @param baseUrl provider endpoint base URL; {@code null} becomes an empty string + * @param model model identifier; {@code null} becomes an empty string + * @param timeoutSeconds timeout value; {@code null} becomes an empty string + * @param apiKey API-key state; when {@code null}, an unresolved blank key state is used + */ + public GuiProviderConfigurationState { + baseUrl = baseUrl == null ? "" : baseUrl; + model = model == null ? "" : model; + timeoutSeconds = timeoutSeconds == null ? "" : timeoutSeconds; + apiKey = Objects.requireNonNullElse(apiKey, GuiProviderApiKeyState.unresolved()); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiValueOrigin.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiValueOrigin.java new file mode 100644 index 0000000..ee170d6 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiValueOrigin.java @@ -0,0 +1,25 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +/** + * Describes the effective origin of a configuration value shown by the editor. + *

+ * The enum is intentionally broad enough to accommodate later provenance display for + * provider-specific values such as API keys. + */ +public enum GuiValueOrigin { + + /** The value has not been resolved to a concrete origin yet. */ + UNKNOWN, + + /** The value comes from the editable property value stored in the configuration file. */ + PROPERTY_VALUE, + + /** The value comes from a provider-specific environment variable. */ + ENVIRONMENT_VARIABLE, + + /** The value comes from the legacy environment variable accepted for compatibility. */ + LEGACY_ENVIRONMENT_VARIABLE, + + /** The value is a technical default supplied by the application. */ + DEFAULT_VALUE +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/package-info.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/package-info.java new file mode 100644 index 0000000..00337bf --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/package-info.java @@ -0,0 +1,12 @@ +/** + * Editor state and template model for the JavaFX configuration editor. + *

+ * This package contains the GUI-side representation of configuration data that can be edited + * independently from file I/O and validation. It separates loaded file snapshots, baseline + * editor values, current editor values, provider-specific API key state, and the derived + * dirty-state view used by the GUI. + *

+ * The classes in this package are intentionally free of JavaFX controls so they can be reused + * by later GUI layers without coupling the model to a particular layout implementation. + */ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java new file mode 100644 index 0000000..a673a60 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java @@ -0,0 +1,81 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class GuiConfigurationEditorStateTest { + + @Test + void dirtyStateReflectsComparisonAgainstBaseline() { + GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate(); + GuiConfigurationValues editedValues = new GuiConfigurationValues( + state.values().sourceFolder(), + state.values().targetFolder(), + state.values().sqliteFile(), + state.values().promptTemplateFile(), + state.values().runtimeLockFile(), + state.values().logDirectory(), + state.values().logLevel(), + state.values().maxRetriesTransient(), + state.values().maxPages(), + state.values().maxTextCharacters(), + "maybe", + "claude-42", + state.values().providerConfigurations()); + + GuiConfigurationEditorState dirty = state.withValues(editedValues); + + assertTrue(dirty.isDirty()); + assertEquals(state.baselineValues(), dirty.baselineValues()); + assertEquals(editedValues, dirty.values()); + assertEquals(GuiChangeState.DIRTY, dirty.changeState()); + + GuiConfigurationEditorState cleanAgain = dirty.markClean(); + assertFalse(cleanAgain.isDirty()); + assertEquals(state.baselineValues(), cleanAgain.baselineValues()); + assertEquals(state.baselineValues(), cleanAgain.values()); + assertEquals(GuiChangeState.CLEAN, cleanAgain.changeState()); + } + + @Test + void loadedFileSnapshotCanBeAttachedWithoutChangingDirtyState() { + GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate(); + GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot( + java.nio.file.Path.of("config/application.properties"), + new java.util.Properties()); + + GuiConfigurationEditorState withSnapshot = state.withLoadedFileSnapshot(snapshot); + + assertTrue(withSnapshot.hasLoadedFileSnapshot()); + assertFalse(withSnapshot.isNewConfiguration()); + assertEquals(snapshot, withSnapshot.loadedFileSnapshot().orElseThrow()); + assertFalse(withSnapshot.isDirty()); + } + + @Test + void resettingTheCurrentValuesToTheBaselineMakesTheEditorCleanAgain() { + GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate(); + GuiConfigurationValues editedValues = new GuiConfigurationValues( + state.values().sourceFolder(), + state.values().targetFolder(), + state.values().sqliteFile(), + state.values().promptTemplateFile(), + state.values().runtimeLockFile(), + state.values().logDirectory(), + state.values().logLevel(), + state.values().maxRetriesTransient(), + state.values().maxPages(), + state.values().maxTextCharacters(), + "true", + "openai-compatible", + state.values().providerConfigurations()); + + GuiConfigurationEditorState reverted = state.withValues(editedValues).withValues(state.baselineValues()); + + assertFalse(reverted.isDirty()); + assertEquals(state.baselineValues(), reverted.values()); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationFileSnapshotTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationFileSnapshotTest.java new file mode 100644 index 0000000..8b7c256 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationFileSnapshotTest.java @@ -0,0 +1,29 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +class GuiConfigurationFileSnapshotTest { + + @Test + void propertiesAreDefensivelyCopiedOnRead() { + Properties properties = new Properties(); + properties.setProperty("source.folder", "./source"); + + GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(Path.of("config/application.properties"), properties); + + Properties firstRead = snapshot.properties(); + firstRead.setProperty("source.folder", "./changed"); + + Properties secondRead = snapshot.properties(); + assertNotSame(firstRead, secondRead); + assertEquals("./source", secondRead.getProperty("source.folder")); + assertTrue(snapshot.hasFilePath(Path.of("config/application.properties"))); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java new file mode 100644 index 0000000..071225c --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java @@ -0,0 +1,78 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; + +class GuiConfigurationTemplateFactoryTest { + + @Test + void createStandardTemplate_providesCompleteCleanEditorState() { + GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate(); + + assertFalse(state.isDirty()); + assertFalse(state.hasLoadedFileSnapshot()); + assertTrue(state.isNewConfiguration()); + + GuiConfigurationValues values = state.values(); + assertEquals("./work/local/source", values.sourceFolder()); + assertEquals("./work/local/target", values.targetFolder()); + assertEquals("./work/local/pdf-umbenenner.db", values.sqliteFile()); + assertEquals("./config/prompts/template.txt", values.promptTemplateFile()); + assertEquals("./work/local/pdf-umbenenner.lock", values.runtimeLockFile()); + assertEquals("./work/local/logs", values.logDirectory()); + assertEquals("INFO", values.logLevel()); + assertEquals("3", values.maxRetriesTransient()); + assertEquals("10", values.maxPages()); + assertEquals("5000", values.maxTextCharacters()); + assertEquals("false", values.logAiSensitive()); + assertEquals(AiProviderFamily.CLAUDE.getIdentifier(), values.activeProviderFamily()); + + List providerOrder = values.providerConfigurations().keySet().stream().toList(); + assertEquals(List.of(AiProviderFamily.CLAUDE, AiProviderFamily.OPENAI_COMPATIBLE), providerOrder); + + GuiProviderConfigurationState claude = values.providerConfiguration(AiProviderFamily.CLAUDE); + assertNotNull(claude); + assertEquals("https://api.anthropic.com", claude.baseUrl()); + assertEquals("claude-3-5-sonnet-20241022", claude.model()); + assertEquals("60", claude.timeoutSeconds()); + assertEquals("", claude.apiKey().propertyValue()); + assertEquals(GuiValueOrigin.UNKNOWN, claude.apiKey().effectiveValueOrigin()); + + GuiProviderConfigurationState openAi = values.providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE); + assertNotNull(openAi); + assertEquals("https://api.openai.com/v1", openAi.baseUrl()); + assertEquals("gpt-4o-mini", openAi.model()); + assertEquals("30", openAi.timeoutSeconds()); + assertEquals("", openAi.apiKey().propertyValue()); + assertEquals(GuiValueOrigin.UNKNOWN, openAi.apiKey().effectiveValueOrigin()); + } + + @Test + void providerConfigurationMap_isImmutableFromOutside() { + GuiConfigurationValues values = GuiConfigurationTemplateFactory.createStandardValues(); + + assertThrows(UnsupportedOperationException.class, () -> + values.providerConfigurations().put(AiProviderFamily.CLAUDE, GuiProviderConfigurationState.blank())); + } + + @Test + void withActiveProviderFamily_reusesProviderBlocksAndChangesSelection() { + GuiConfigurationValues values = GuiConfigurationTemplateFactory.createStandardValues(); + + GuiConfigurationValues switched = values.withActiveProviderFamily(AiProviderFamily.OPENAI_COMPATIBLE.getIdentifier()); + + assertEquals(AiProviderFamily.OPENAI_COMPATIBLE.getIdentifier(), switched.activeProviderFamily()); + assertEquals(values.providerConfigurations(), switched.providerConfigurations()); + assertEquals(values.sourceFolder(), switched.sourceFolder()); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValuesTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValuesTest.java new file mode 100644 index 0000000..a8d7b85 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationValuesTest.java @@ -0,0 +1,65 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; + +class GuiConfigurationValuesTest { + + @Test + void rawGeneralValuesRemainUntouched() { + Map providerConfigurations = new LinkedHashMap<>(); + providerConfigurations.put(AiProviderFamily.CLAUDE, GuiProviderConfigurationState.blank()); + + GuiConfigurationValues values = new GuiConfigurationValues( + "./source", + "./target", + "./config/db.sqlite", + "./config/prompt.txt", + "./config/runtime.lock", + "./logs", + "DEBUG", + "12", + "34", + "56", + "maybe", + "not-a-provider-family", + providerConfigurations); + + assertEquals("maybe", values.logAiSensitive()); + assertEquals("not-a-provider-family", values.activeProviderFamily()); + assertEquals(GuiProviderConfigurationState.blank(), values.providerConfiguration(AiProviderFamily.CLAUDE)); + } + + @Test + void providerConfigurationMapIsDefensivelyCopied() { + Map providerConfigurations = new LinkedHashMap<>(); + providerConfigurations.put(AiProviderFamily.CLAUDE, GuiProviderConfigurationState.blank()); + + GuiConfigurationValues values = new GuiConfigurationValues( + "./source", + "./target", + "./config/db.sqlite", + "./config/prompt.txt", + "./config/runtime.lock", + "./logs", + "DEBUG", + "12", + "34", + "56", + "true", + AiProviderFamily.CLAUDE.getIdentifier(), + providerConfigurations); + + providerConfigurations.put(AiProviderFamily.OPENAI_COMPATIBLE, GuiProviderConfigurationState.blank()); + + assertNotSame(providerConfigurations, values.providerConfigurations()); + assertEquals(1, values.providerConfigurations().size()); + } +}