M11 vollständig abgeschlossen (AP-001 bis AP-007)
- AP-001: Kernobjekte und Port-Verträge (ModelCatalog-Port, sealed Result-Typen, ApiKeyOrigin, GUI-Modell- und Meldungs-Records) - AP-002: Provider-ComboBox, exklusiver Providerbereich, zustandsbewahrender Providerwechsel - AP-003: HTTP-Adapter für Modellabruf (Claude, OpenAI-kompatibel) mit vollständigem Error-Mapping und Dispatcher im Bootstrap - AP-004: Automatischer Modellabruf bei Providerwechsel, Aktion "Modelle neu laden", Umschaltung zwischen Modell-ComboBox und Modell-Textfeld, Worker-Thread-Kapselung - AP-005: Automatische Editorvalidierung (Pflichtfelder, Warnschwellen max.text.characters, Plausibilitätshinweise max.pages, API-Key-Herkunftsauflösung mit Vorrangregel) - AP-006: Zentraler Meldungsbereich mit vier Severity-Stufen, feldnahe rote Fehlermeldungen, API-Key-Herkunftsanzeige - AP-007: Integrations- und Regressionstests, Timeout-Mapping-Tests, Replace-Semantik für wiederholte Modellabruf-Meldungen Hexagonale Architektur eingehalten, Application- und Domain-Schicht bleiben infrastrukturfrei. Threadingmodell konsequent umgesetzt. Naming-Regel und JavaDoc-Standard durchgängig beachtet. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+778
-108
File diff suppressed because it is too large
Load Diff
+277
@@ -0,0 +1,277 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Coordinates asynchronous model catalogue retrieval for the GUI provider section.
|
||||
* <p>
|
||||
* This coordinator is responsible for:
|
||||
* <ul>
|
||||
* <li>Triggering a background HTTP call via {@link AiModelCatalogPort} on a dedicated
|
||||
* daemon thread named {@code gui-model-catalog}.</li>
|
||||
* <li>Returning the result to the JavaFX Application Thread via {@code Platform.runLater}.</li>
|
||||
* <li>Updating the per-provider {@link GuiModelFieldContainer} to show either a
|
||||
* non-editable {@code ComboBox} (success) or a manual text field (all other cases).</li>
|
||||
* <li>Appending a {@link GuiMessageEntry} to the supplied pending-messages list for each
|
||||
* completed retrieval attempt, so later GUI layers can display the result.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* The worker thread factory is injectable so tests can supply a synchronous or latch-guarded
|
||||
* executor without spinning a real OS thread.
|
||||
* <p>
|
||||
* This class is not thread-safe by itself. All methods intended to mutate GUI state must be
|
||||
* called on the JavaFX Application Thread. Background threads only interact through
|
||||
* {@code Platform.runLater}.
|
||||
*/
|
||||
public final class GuiModelCatalogCoordinator {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
|
||||
|
||||
/** Default timeout used when no timeout is configured in the provider state. */
|
||||
static final int DEFAULT_TIMEOUT_SECONDS = 10;
|
||||
|
||||
private final AiModelCatalogPort modelCatalogPort;
|
||||
private final List<GuiMessageEntry> pendingMessages;
|
||||
|
||||
/**
|
||||
* Factory for the background worker thread. Package-private to allow test substitution.
|
||||
* The default creates a daemon thread named {@code gui-model-catalog}.
|
||||
*/
|
||||
Function<Runnable, Thread> modelCatalogThreadFactory;
|
||||
|
||||
/** Per-provider field containers; populated by the workspace when it builds provider blocks. */
|
||||
private final Map<AiProviderFamily, GuiModelFieldContainer> fieldContainers =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Consumer that delivers the retrieval result. In production this wraps the call in
|
||||
* {@code Platform.runLater}. In tests it can be replaced with a direct call so the result
|
||||
* is applied immediately on the worker thread without needing an FX queue drain.
|
||||
* Package-private to allow test substitution.
|
||||
*/
|
||||
java.util.function.Consumer<Runnable> resultDelivery = Platform::runLater;
|
||||
|
||||
/**
|
||||
* Optional callback invoked on the JavaFX Application Thread after each retrieval result has
|
||||
* been applied. The workspace uses this hook to refresh the central message area and field-error
|
||||
* labels without coupling the coordinator to the workspace implementation.
|
||||
* Package-private to allow substitution in tests.
|
||||
*/
|
||||
Runnable postResultCallback = () -> { };
|
||||
|
||||
/**
|
||||
* Creates a coordinator backed by the given catalogue port and shared message list.
|
||||
*
|
||||
* @param modelCatalogPort port used for background HTTP calls; must not be {@code null}
|
||||
* @param pendingMessages mutable list to append result messages to; must not be {@code null}
|
||||
*/
|
||||
public GuiModelCatalogCoordinator(AiModelCatalogPort modelCatalogPort,
|
||||
List<GuiMessageEntry> pendingMessages) {
|
||||
this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
|
||||
"modelCatalogPort must not be null");
|
||||
this.pendingMessages = Objects.requireNonNull(pendingMessages,
|
||||
"pendingMessages must not be null");
|
||||
this.modelCatalogThreadFactory = task -> {
|
||||
Thread t = new Thread(task, "gui-model-catalog");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a {@link GuiModelFieldContainer} for the given provider family.
|
||||
* <p>
|
||||
* Must be called on the JavaFX Application Thread before the first retrieval is triggered.
|
||||
*
|
||||
* @param family the provider family this container belongs to; must not be {@code null}
|
||||
* @param container the container to register; must not be {@code null}
|
||||
*/
|
||||
public void registerFieldContainer(AiProviderFamily family, GuiModelFieldContainer container) {
|
||||
Objects.requireNonNull(family, "family must not be null");
|
||||
Objects.requireNonNull(container, "container must not be null");
|
||||
fieldContainers.put(family, container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an asynchronous model catalogue retrieval for the given provider family.
|
||||
* <p>
|
||||
* The retrieval is performed on a background worker thread. The result is delivered back
|
||||
* to the JavaFX Application Thread via {@code Platform.runLater}. The registered
|
||||
* {@link GuiModelFieldContainer} for the provider is updated accordingly, and a
|
||||
* {@link GuiMessageEntry} is appended to the pending-messages list.
|
||||
* <p>
|
||||
* If no field container is registered for the provider, the call is a no-op.
|
||||
* <p>
|
||||
* Must be called on the JavaFX Application Thread.
|
||||
*
|
||||
* @param family the provider family to retrieve models for; must not be {@code null}
|
||||
* @param providerState the current editor state for the provider; must not be {@code null}
|
||||
*/
|
||||
public void triggerModelRetrieval(AiProviderFamily family,
|
||||
GuiProviderConfigurationState providerState) {
|
||||
Objects.requireNonNull(family, "family must not be null");
|
||||
Objects.requireNonNull(providerState, "providerState must not be null");
|
||||
|
||||
GuiModelFieldContainer container = fieldContainers.get(family);
|
||||
if (container == null) {
|
||||
LOG.debug("GUI-Modellabruf: Kein Feld-Container für Provider '{}' registriert – übersprungen.",
|
||||
family.getIdentifier());
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the current manual value before starting the background call.
|
||||
String previousManualValue = container.currentModelValue();
|
||||
|
||||
// Build the request from the current editor state.
|
||||
ModelCatalogRequest request = buildRequest(family, providerState);
|
||||
|
||||
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet.",
|
||||
family.getIdentifier());
|
||||
|
||||
Runnable task = () -> {
|
||||
ModelCatalogResult result = modelCatalogPort.fetchAvailableModels(request);
|
||||
resultDelivery.accept(() -> {
|
||||
applyResult(family, container, result, previousManualValue);
|
||||
postResultCallback.run();
|
||||
});
|
||||
};
|
||||
|
||||
Thread worker = modelCatalogThreadFactory.apply(task);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the result of a completed model catalogue retrieval to the field container and
|
||||
* appends a message entry to the pending-messages list.
|
||||
* <p>
|
||||
* Must only be called on the JavaFX Application Thread (via {@code Platform.runLater}).
|
||||
*
|
||||
* @param family the provider family that was queried; must not be {@code null}
|
||||
* @param container the field container to update; must not be {@code null}
|
||||
* @param result the retrieval result; must not be {@code null}
|
||||
* @param previousManualValue the model value that was in the text field before the call
|
||||
*/
|
||||
private void applyResult(AiProviderFamily family,
|
||||
GuiModelFieldContainer container,
|
||||
ModelCatalogResult result,
|
||||
String previousManualValue) {
|
||||
// Remove any previous message entries from an earlier retrieval so messages do not
|
||||
// accumulate across repeated triggers of the same retrieval action.
|
||||
pendingMessages.removeIf(msg -> "Modellabruf".equals(msg.source().orElse("")));
|
||||
|
||||
String displayName = displayNameFor(family);
|
||||
|
||||
switch (result) {
|
||||
case ModelCatalogResult.Success success -> {
|
||||
List<String> models = success.models();
|
||||
container.applyModelList(models, previousManualValue);
|
||||
String message = "Modellliste für " + displayName + " geladen ("
|
||||
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, "Modellabruf"));
|
||||
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||
}
|
||||
case ModelCatalogResult.EmptyList emptyList -> {
|
||||
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||
String message = "Provider " + displayName
|
||||
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf"));
|
||||
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||
}
|
||||
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
|
||||
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
|
||||
+ ". Manuelle Eingabe aktiv.";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, "Modellabruf"));
|
||||
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||
}
|
||||
case ModelCatalogResult.TechnicalFailure failure -> {
|
||||
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
|
||||
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
|
||||
+ "). Manuelle Eingabe aktiv.";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, "Modellabruf"));
|
||||
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
|
||||
message, failure.errorDetail(), family.getIdentifier());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link ModelCatalogRequest} from the current provider editor state.
|
||||
* <p>
|
||||
* Missing or blank values are passed as {@code Optional.empty()} so the adapter can apply
|
||||
* its own defaults or return {@link ModelCatalogResult.IncompleteConfiguration} if required
|
||||
* values are absent.
|
||||
*
|
||||
* @param family the target provider family; must not be {@code null}
|
||||
* @param providerState the current provider editor state; must not be {@code null}
|
||||
* @return a new request; never {@code null}
|
||||
*/
|
||||
private static ModelCatalogRequest buildRequest(AiProviderFamily family,
|
||||
GuiProviderConfigurationState providerState) {
|
||||
Optional<String> baseUrl = Optional.ofNullable(providerState.baseUrl())
|
||||
.filter(s -> !s.isBlank());
|
||||
Optional<String> apiKey = Optional.ofNullable(providerState.apiKey())
|
||||
.map(keyState -> keyState.propertyValue())
|
||||
.filter(s -> !s.isBlank());
|
||||
|
||||
int timeout = DEFAULT_TIMEOUT_SECONDS;
|
||||
String timeoutStr = providerState.timeoutSeconds();
|
||||
if (timeoutStr != null && !timeoutStr.isBlank()) {
|
||||
try {
|
||||
int parsed = Integer.parseInt(timeoutStr.trim());
|
||||
if (parsed > 0) {
|
||||
timeout = parsed;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Use default.
|
||||
}
|
||||
}
|
||||
|
||||
return new ModelCatalogRequest(family.getIdentifier(), baseUrl, apiKey, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable display name for the given provider family.
|
||||
*
|
||||
* @param family the provider family; must not be {@code null}
|
||||
* @return the display name; never {@code null}
|
||||
*/
|
||||
private static String displayNameFor(AiProviderFamily family) {
|
||||
return switch (family) {
|
||||
case CLAUDE -> "Claude";
|
||||
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an unmodifiable snapshot of the pending messages collected so far.
|
||||
* <p>
|
||||
* This method is intended for tests that need to inspect the message list after
|
||||
* a retrieval completes.
|
||||
*
|
||||
* @return unmodifiable list of pending messages; never {@code null}
|
||||
*/
|
||||
public List<GuiMessageEntry> pendingMessagesSnapshot() {
|
||||
return List.copyOf(pendingMessages);
|
||||
}
|
||||
}
|
||||
+36
-7
@@ -5,26 +5,40 @@ import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
||||
|
||||
/**
|
||||
* Immutable startup data for the GUI adapter.
|
||||
* <p>
|
||||
* 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.
|
||||
* Carries the initial editor state, the optional startup notice, the file-loading callback,
|
||||
* the file-writing callback that the workspace uses for native save actions, the
|
||||
* {@link AiModelCatalogPort} used to retrieve available AI model lists on demand, and the
|
||||
* {@link ApiKeyResolutionPort} used by the editor validation to determine the effective
|
||||
* API key provenance from environment variables.
|
||||
* <p>
|
||||
* All ports are supplied by Bootstrap so that the GUI adapter does not need to know about
|
||||
* provider-specific HTTP details or adapter wiring.
|
||||
*/
|
||||
public record GuiStartupContext(
|
||||
GuiConfigurationEditorState initialState,
|
||||
Optional<String> startupNotice,
|
||||
GuiConfigurationFileLoader configurationFileLoader,
|
||||
GuiConfigurationFileWriter configurationFileWriter) {
|
||||
GuiConfigurationFileWriter configurationFileWriter,
|
||||
AiModelCatalogPort modelCatalogPort,
|
||||
ApiKeyResolutionPort apiKeyResolutionPort) {
|
||||
|
||||
/**
|
||||
* Creates a startup context.
|
||||
*
|
||||
* @param initialState initial editor state; must not be {@code null}
|
||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||
* @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}
|
||||
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||
*/
|
||||
public GuiStartupContext {
|
||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||
@@ -33,10 +47,20 @@ public record GuiStartupContext(
|
||||
"configurationFileLoader must not be null");
|
||||
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
|
||||
"configurationFileWriter must not be null");
|
||||
modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
|
||||
"modelCatalogPort must not be null");
|
||||
apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
|
||||
"apiKeyResolutionPort must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a blank startup context with no loader or writer side effects.
|
||||
* Creates a blank startup context with no loader or writer side effects, a no-op model
|
||||
* catalogue port, and a no-op API key resolution port.
|
||||
* <p>
|
||||
* The no-op model catalogue port always returns {@code IncompleteConfiguration}.
|
||||
* The no-op API key resolution port always returns {@code ABSENT}.
|
||||
* This is safe for environments where no Bootstrap wiring is present, such as isolated
|
||||
* GUI tests.
|
||||
*
|
||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||
* @return a startup context for the unloaded editor start
|
||||
@@ -46,6 +70,11 @@ public record GuiStartupContext(
|
||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||
startupNotice,
|
||||
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||
(values, path) -> GuiConfigurationSaveResult.saved(path));
|
||||
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||
request -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.ModelCatalogResult.IncompleteConfiguration(
|
||||
request.providerIdentifier(),
|
||||
"Kein Modellkatalog in diesem Startkontext verfügbar."),
|
||||
(family, propertyValue) -> EffectiveApiKeyDescriptor.absent());
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import javafx.util.StringConverter;
|
||||
|
||||
/**
|
||||
* A JavaFX {@link StringConverter} that maps {@link AiProviderFamily} constants to
|
||||
* German display labels and back.
|
||||
* <p>
|
||||
* Used by the provider selection {@code ComboBox} to show human-readable German names
|
||||
* while keeping the underlying model type-safe. The reverse conversion
|
||||
* ({@link #fromString(String)}) supports the same label strings produced by
|
||||
* {@link #toString(AiProviderFamily)} so that a ComboBox configured as non-editable
|
||||
* can still convert its selected text back to the enum constant when needed.
|
||||
* <p>
|
||||
* Returns {@code null} for inputs that do not match any known constant to signal an
|
||||
* unrecognised display label.
|
||||
*/
|
||||
public final class AiProviderFamilyStringConverter extends StringConverter<AiProviderFamily> {
|
||||
|
||||
/**
|
||||
* Creates a new converter instance.
|
||||
*/
|
||||
public AiProviderFamilyStringConverter() {
|
||||
// Default constructor.
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the German display label for the given provider family.
|
||||
*
|
||||
* @param family the provider family to convert; may be {@code null}
|
||||
* @return the German display label, or an empty string when {@code family} is {@code null}
|
||||
*/
|
||||
@Override
|
||||
public String toString(AiProviderFamily family) {
|
||||
if (family == null) {
|
||||
return "";
|
||||
}
|
||||
return switch (family) {
|
||||
case CLAUDE -> "Claude";
|
||||
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a German display label back to its {@link AiProviderFamily} constant.
|
||||
*
|
||||
* @param label the display label as produced by {@link #toString(AiProviderFamily)};
|
||||
* may be {@code null}
|
||||
* @return the matching constant, or {@code null} when the label is not recognised
|
||||
*/
|
||||
@Override
|
||||
public AiProviderFamily fromString(String label) {
|
||||
if (label == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (label) {
|
||||
case "Claude" -> AiProviderFamily.CLAUDE;
|
||||
case "OpenAI-kompatibel" -> AiProviderFamily.OPENAI_COMPATIBLE;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Represents the result of validating the current editor state in the GUI configuration editor.
|
||||
* <p>
|
||||
* Each validation run produces one immutable result containing all findings split into two
|
||||
* complementary views:
|
||||
* <ul>
|
||||
* <li>{@code messages} – a consolidated list of {@link GuiMessageEntry} objects that feed
|
||||
* the central message area.</li>
|
||||
* <li>{@code fieldFindings} – field-specific {@link GuiFieldFinding} objects that are
|
||||
* rendered directly below the affected input fields.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* A single root cause may appear in both lists: once as a central message (with full context)
|
||||
* and once as a compact field finding (with a short, field-specific description).
|
||||
* <p>
|
||||
* The {@code evaluatedAt} timestamp records when the validation ran; the GUI may use it to
|
||||
* determine whether a displayed result is still current.
|
||||
* <p>
|
||||
* This record contains no JavaFX references and can be created and inspected on any thread.
|
||||
*
|
||||
* @param messages consolidated list of message entries for the central message area;
|
||||
* never {@code null}
|
||||
* @param fieldFindings list of field-level findings; never {@code null}
|
||||
* @param evaluatedAt instant at which the validation was performed; never {@code null}
|
||||
*/
|
||||
public record GuiEditorValidationResult(
|
||||
List<GuiMessageEntry> messages,
|
||||
List<GuiFieldFinding> fieldFindings,
|
||||
Instant evaluatedAt) {
|
||||
|
||||
/**
|
||||
* Creates a new validation result.
|
||||
*
|
||||
* @param messages central message entries; must not be {@code null}
|
||||
* @param fieldFindings field-level findings; must not be {@code null}
|
||||
* @param evaluatedAt validation timestamp; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
*/
|
||||
public GuiEditorValidationResult {
|
||||
Objects.requireNonNull(messages, "messages must not be null");
|
||||
Objects.requireNonNull(fieldFindings, "fieldFindings must not be null");
|
||||
Objects.requireNonNull(evaluatedAt, "evaluatedAt must not be null");
|
||||
messages = List.copyOf(messages);
|
||||
fieldFindings = List.copyOf(fieldFindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an empty validation result representing the state before any validation has run.
|
||||
* <p>
|
||||
* Callers must not interpret an empty result as "no errors found"; they should wait for
|
||||
* a non-empty result from the first actual validation run.
|
||||
*
|
||||
* @return an empty result with the current instant as timestamp; never {@code null}
|
||||
*/
|
||||
public static GuiEditorValidationResult empty() {
|
||||
return new GuiEditorValidationResult(List.of(), List.of(), Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when at least one message or field finding has severity
|
||||
* {@link GuiMessageSeverity#ERROR}.
|
||||
* <p>
|
||||
* A result with errors indicates that the current editor state is not operational and
|
||||
* should not be relied upon to start a processing run without correction.
|
||||
*
|
||||
* @return {@code true} when at least one error is present
|
||||
*/
|
||||
public boolean hasErrors() {
|
||||
boolean messageError = messages.stream()
|
||||
.anyMatch(m -> m.severity() == GuiMessageSeverity.ERROR);
|
||||
boolean fieldError = fieldFindings.stream()
|
||||
.anyMatch(f -> f.severity() == GuiMessageSeverity.ERROR);
|
||||
return messageError || fieldError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when the findings list contains at least one finding for the
|
||||
* requested field key, regardless of severity.
|
||||
*
|
||||
* @param fieldKey the property key to look up; must not be {@code null}
|
||||
* @return {@code true} when at least one finding refers to the requested field
|
||||
*/
|
||||
public boolean hasFieldFindingFor(String fieldKey) {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
return fieldFindings.stream().anyMatch(f -> f.fieldKey().equals(fieldKey));
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Represents a field-level validation finding that is displayed directly below the affected
|
||||
* input field in the GUI configuration editor.
|
||||
* <p>
|
||||
* Field-level findings complement the central message area: the central area shows all findings
|
||||
* in a consolidated list while this type carries the finding directly to the specific field it
|
||||
* relates to, making it easier for the user to identify and correct the problem.
|
||||
* <p>
|
||||
* The {@code fieldKey} uses the property key as defined in the {@code .properties} file
|
||||
* (e.g., {@code "source.folder"}, {@code "ai.provider.openai-compatible.apiKey"}).
|
||||
* Using the property key as the field identifier keeps the validation model stable and independent
|
||||
* of GUI layout changes.
|
||||
* <p>
|
||||
* Field-level findings are always rendered as small red German-language text directly beneath
|
||||
* the affected control. Findings with severity {@link GuiMessageSeverity#INFO} or
|
||||
* {@link GuiMessageSeverity#HINT} may also be shown field-near when the context is helpful.
|
||||
* <p>
|
||||
* This record contains no JavaFX references and is safe to create on any thread.
|
||||
*
|
||||
* @param fieldKey the property key identifying the affected configuration field; never {@code null}
|
||||
* @param severity the severity of this finding; never {@code null}
|
||||
* @param text short, German-language description of the problem; never {@code null}
|
||||
*/
|
||||
public record GuiFieldFinding(
|
||||
String fieldKey,
|
||||
GuiMessageSeverity severity,
|
||||
String text) {
|
||||
|
||||
/**
|
||||
* Creates a new field-level finding.
|
||||
*
|
||||
* @param fieldKey property key of the affected field; must not be {@code null}
|
||||
* @param severity severity of the finding; must not be {@code null}
|
||||
* @param text German-language problem description; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
*/
|
||||
public GuiFieldFinding {
|
||||
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||
Objects.requireNonNull(severity, "severity must not be null");
|
||||
Objects.requireNonNull(text, "text must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error-severity field finding.
|
||||
*
|
||||
* @param fieldKey property key of the affected field; must not be {@code null}
|
||||
* @param text German-language problem description; must not be {@code null}
|
||||
* @return a new finding with severity {@link GuiMessageSeverity#ERROR}
|
||||
*/
|
||||
public static GuiFieldFinding error(String fieldKey, String text) {
|
||||
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.ERROR, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a warning-severity field finding.
|
||||
*
|
||||
* @param fieldKey property key of the affected field; must not be {@code null}
|
||||
* @param text German-language problem description; must not be {@code null}
|
||||
* @return a new finding with severity {@link GuiMessageSeverity#WARNING}
|
||||
*/
|
||||
public static GuiFieldFinding warning(String fieldKey, String text) {
|
||||
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.WARNING, text);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Represents the model identifier entered manually by the user when no remote model list
|
||||
* is available for the active provider.
|
||||
* <p>
|
||||
* This record captures both the provider context and the user-supplied model name so that
|
||||
* later GUI layers can decide whether the value is still applicable after a provider change
|
||||
* or a successful remote list retrieval.
|
||||
* <p>
|
||||
* A manually entered model name is discarded when a remote model list is subsequently loaded
|
||||
* and the previously entered value does not appear in that list.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider for which the model was entered;
|
||||
* never {@code null}
|
||||
* @param modelName model identifier as typed by the user; never {@code null},
|
||||
* but may be blank when the user has not yet entered anything
|
||||
*/
|
||||
public record GuiManualModelEntry(
|
||||
String providerIdentifier,
|
||||
String modelName) {
|
||||
|
||||
/**
|
||||
* Creates a new manual model entry.
|
||||
*
|
||||
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||
* @param modelName model name as entered by the user; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
*/
|
||||
public GuiManualModelEntry {
|
||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||
Objects.requireNonNull(modelName, "modelName must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the model name is non-blank, i.e. whether the user has entered something.
|
||||
*
|
||||
* @return {@code true} when the model name contains at least one non-whitespace character
|
||||
*/
|
||||
public boolean hasModelName() {
|
||||
return !modelName.isBlank();
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Represents a single entry in the central message area of the GUI configuration editor.
|
||||
* <p>
|
||||
* Each entry carries a severity level, the message text, an optional source label that
|
||||
* identifies the subsystem that produced the message (e.g., "Modellabruf", "Validierung"),
|
||||
* and a timestamp. The GUI renders the {@link GuiMessageSeverity#getPrefixLabel() prefix} of
|
||||
* the severity in colour while the message text itself remains black.
|
||||
* <p>
|
||||
* Instances are immutable and contain no JavaFX references; they are safe to create on
|
||||
* background threads and pass to the JavaFX Application Thread via {@code Platform.runLater}.
|
||||
*
|
||||
* @param severity the severity of this message; never {@code null}
|
||||
* @param text the message text; never {@code null}
|
||||
* @param source optional label identifying the origin subsystem; empty when not applicable
|
||||
* @param timestamp the instant at which the message was created; never {@code null}
|
||||
*/
|
||||
public record GuiMessageEntry(
|
||||
GuiMessageSeverity severity,
|
||||
String text,
|
||||
Optional<String> source,
|
||||
Instant timestamp) {
|
||||
|
||||
/**
|
||||
* Creates a new message entry.
|
||||
*
|
||||
* @param severity severity level; must not be {@code null}
|
||||
* @param text message text; must not be {@code null}
|
||||
* @param source optional source label; {@code null} is treated as {@link Optional#empty()}
|
||||
* @param timestamp creation timestamp; must not be {@code null}
|
||||
* @throws NullPointerException if {@code severity}, {@code text}, or {@code timestamp} is {@code null}
|
||||
*/
|
||||
public GuiMessageEntry {
|
||||
Objects.requireNonNull(severity, "severity must not be null");
|
||||
Objects.requireNonNull(text, "text must not be null");
|
||||
Objects.requireNonNull(timestamp, "timestamp must not be null");
|
||||
source = source == null ? Optional.empty() : source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message entry without a source label, using the current instant as timestamp.
|
||||
*
|
||||
* @param severity severity level; must not be {@code null}
|
||||
* @param text message text; must not be {@code null}
|
||||
* @return a new entry; never {@code null}
|
||||
*/
|
||||
public static GuiMessageEntry of(GuiMessageSeverity severity, String text) {
|
||||
return new GuiMessageEntry(severity, text, Optional.empty(), Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message entry with a source label, using the current instant as timestamp.
|
||||
*
|
||||
* @param severity severity level; must not be {@code null}
|
||||
* @param text message text; must not be {@code null}
|
||||
* @param source source subsystem label; must not be {@code null}
|
||||
* @return a new entry; never {@code null}
|
||||
*/
|
||||
public static GuiMessageEntry of(GuiMessageSeverity severity, String text, String source) {
|
||||
Objects.requireNonNull(source, "source must not be null");
|
||||
return new GuiMessageEntry(severity, text, Optional.of(source), Instant.now());
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
/**
|
||||
* Defines the four fixed severity levels for messages displayed in the central message area
|
||||
* of the GUI configuration editor.
|
||||
* <p>
|
||||
* Each level carries a German-language prefix string that is displayed in colour at the start
|
||||
* of each message line. The remainder of the message text is always rendered in black,
|
||||
* regardless of severity.
|
||||
* <p>
|
||||
* The colour hints in this enum are expressed as CSS colour strings to avoid a compile-time
|
||||
* dependency on JavaFX. Rendering code in the GUI layer must convert the hint to a
|
||||
* {@code javafx.scene.paint.Color} or equivalent.
|
||||
* <p>
|
||||
* Severity levels ordered from least to most critical:
|
||||
* <ol>
|
||||
* <li>{@link #INFO}</li>
|
||||
* <li>{@link #HINT}</li>
|
||||
* <li>{@link #WARNING}</li>
|
||||
* <li>{@link #ERROR}</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* This enum contains no JavaFX references and is safe to use in unit-tested view-model code.
|
||||
*/
|
||||
public enum GuiMessageSeverity {
|
||||
|
||||
/** Neutral informational message, no action required. */
|
||||
INFO("Info:", "#1565c0"),
|
||||
|
||||
/** Helpful hint that the user may want to act on. */
|
||||
HINT("Hinweis:", "#558b2f"),
|
||||
|
||||
/** Configuration value is technically acceptable but risky or unusual. */
|
||||
WARNING("Warnung:", "#e65100"),
|
||||
|
||||
/** Configuration value is invalid or the state is not operational. */
|
||||
ERROR("Fehler:", "#b71c1c");
|
||||
|
||||
private final String prefixLabel;
|
||||
private final String prefixCssColour;
|
||||
|
||||
GuiMessageSeverity(String prefixLabel, String prefixCssColour) {
|
||||
this.prefixLabel = prefixLabel;
|
||||
this.prefixCssColour = prefixCssColour;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the German-language prefix label shown at the start of each message line of this severity.
|
||||
* <p>
|
||||
* Only the prefix is rendered in colour; the remaining message text is always black.
|
||||
*
|
||||
* @return the prefix label; never {@code null}
|
||||
*/
|
||||
public String getPrefixLabel() {
|
||||
return prefixLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CSS colour string hint that the GUI layer uses to render the prefix in the
|
||||
* appropriate colour.
|
||||
* <p>
|
||||
* The returned value is a CSS hex colour (e.g., {@code "#b71c1c"}) that can be passed to
|
||||
* {@code Color.web()} in JavaFX. The GUI layer is responsible for this conversion; this
|
||||
* enum itself contains no JavaFX dependency.
|
||||
*
|
||||
* @return a CSS colour hint string; never {@code null}
|
||||
*/
|
||||
public String getPrefixCssColour() {
|
||||
return prefixCssColour;
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
/**
|
||||
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
|
||||
* for model identifier input, depending on the current {@link GuiModelSource}.
|
||||
* <p>
|
||||
* When the model source is {@link GuiModelSource#LIST_REMOTE_SUCCESS}, a non-editable
|
||||
* {@code ComboBox} is shown, pre-populated with the remote list and with the first model
|
||||
* pre-selected. In all other cases (including {@link GuiModelSource#NOT_YET_LOADED}) the
|
||||
* manual text field is shown, which may be empty or disabled depending on the source state.
|
||||
* <p>
|
||||
* Exactly one child is {@code visible} and {@code managed} at any time. The other child is
|
||||
* kept in the scene graph with both flags set to {@code false} so that no blank space appears.
|
||||
* <p>
|
||||
* This class contains JavaFX references and must only be used on the JavaFX Application Thread.
|
||||
*/
|
||||
public final class GuiModelFieldContainer extends StackPane {
|
||||
|
||||
private final ComboBox<String> comboBox;
|
||||
private final TextField textField;
|
||||
private final Consumer<String> onModelChange;
|
||||
|
||||
private GuiModelSource currentSource;
|
||||
|
||||
/**
|
||||
* Guard flag that suppresses the change callback while the text field value is being set
|
||||
* programmatically via {@link #setTextFieldValue(String)}. The callback must still fire on
|
||||
* genuine user edits, so the guard is scoped tightly around the programmatic write only.
|
||||
*/
|
||||
private boolean programmaticTextFieldSet = false;
|
||||
|
||||
/**
|
||||
* Creates a new model field container.
|
||||
*
|
||||
* @param initialModelValue the initial model text shown in the text field; may be blank
|
||||
* @param onModelChange callback invoked on every model-value change; must not be {@code null}
|
||||
*/
|
||||
public GuiModelFieldContainer(String initialModelValue, Consumer<String> onModelChange) {
|
||||
this.onModelChange = Objects.requireNonNull(onModelChange, "onModelChange must not be null");
|
||||
this.currentSource = GuiModelSource.NOT_YET_LOADED;
|
||||
|
||||
this.textField = new TextField(initialModelValue == null ? "" : initialModelValue);
|
||||
this.textField.textProperty().addListener((obs, oldText, newText) -> {
|
||||
if (!programmaticTextFieldSet && !newText.equals(oldText)) {
|
||||
onModelChange.accept(newText);
|
||||
}
|
||||
});
|
||||
|
||||
this.comboBox = new ComboBox<>();
|
||||
this.comboBox.setEditable(false);
|
||||
this.comboBox.valueProperty().addListener((obs, oldVal, newVal) -> {
|
||||
if (newVal != null && !newVal.equals(oldVal)) {
|
||||
onModelChange.accept(newVal);
|
||||
}
|
||||
});
|
||||
|
||||
// Initial state: show text field (NOT_YET_LOADED → manual input)
|
||||
applyVisibility(false);
|
||||
getChildren().addAll(comboBox, textField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently displayed model value.
|
||||
* <p>
|
||||
* When the {@code ComboBox} is active, returns the selected item. When the text field
|
||||
* is active, returns the text field content. Never returns {@code null}.
|
||||
*
|
||||
* @return the current model value; never {@code null}
|
||||
*/
|
||||
public String currentModelValue() {
|
||||
if (currentSource == GuiModelSource.LIST_REMOTE_SUCCESS) {
|
||||
String val = comboBox.getValue();
|
||||
return val == null ? "" : val;
|
||||
}
|
||||
return textField.getText() == null ? "" : textField.getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current model source state.
|
||||
*
|
||||
* @return the current {@link GuiModelSource}; never {@code null}
|
||||
*/
|
||||
public GuiModelSource currentSource() {
|
||||
return currentSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a successful model list and switches to the non-editable {@link ComboBox}.
|
||||
* <p>
|
||||
* If the previously active manual text value is present in the new list it is kept as the
|
||||
* selection; otherwise the first model in the list is pre-selected and the former manual
|
||||
* value is discarded.
|
||||
* <p>
|
||||
* Must be called on the JavaFX Application Thread.
|
||||
*
|
||||
* @param models non-empty list of model identifiers; must not be {@code null} or empty
|
||||
* @param previousManualValue the model text that was in the text field before this call;
|
||||
* used to decide whether to preserve the selection
|
||||
* @throws IllegalArgumentException if {@code models} is empty
|
||||
*/
|
||||
public void applyModelList(List<String> models, String previousManualValue) {
|
||||
Objects.requireNonNull(models, "models must not be null");
|
||||
if (models.isEmpty()) {
|
||||
throw new IllegalArgumentException("models must not be empty");
|
||||
}
|
||||
|
||||
comboBox.getItems().setAll(models);
|
||||
|
||||
// Preserve the previous value only when it appears in the new list.
|
||||
String previous = previousManualValue == null ? "" : previousManualValue;
|
||||
if (!previous.isBlank() && models.contains(previous)) {
|
||||
comboBox.setValue(previous);
|
||||
} else {
|
||||
comboBox.setValue(models.get(0));
|
||||
}
|
||||
|
||||
currentSource = GuiModelSource.LIST_REMOTE_SUCCESS;
|
||||
applyVisibility(true);
|
||||
// Notify the callback about the newly selected value.
|
||||
String selected = comboBox.getValue();
|
||||
if (selected != null) {
|
||||
onModelChange.accept(selected);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to the manual text field with the given fallback source state.
|
||||
* <p>
|
||||
* The text field retains whatever value it currently holds (or the value set programmatically
|
||||
* via {@link #setTextFieldValue(String)}). Must be called on the JavaFX Application Thread.
|
||||
*
|
||||
* @param source the non-success source state; must not be {@link GuiModelSource#LIST_REMOTE_SUCCESS}
|
||||
*/
|
||||
public void applyManualFallback(GuiModelSource source) {
|
||||
Objects.requireNonNull(source, "source must not be null");
|
||||
if (source == GuiModelSource.LIST_REMOTE_SUCCESS) {
|
||||
throw new IllegalArgumentException(
|
||||
"applyManualFallback must not be called with LIST_REMOTE_SUCCESS");
|
||||
}
|
||||
currentSource = source;
|
||||
applyVisibility(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatically sets the text field value without triggering the change callback.
|
||||
* <p>
|
||||
* Useful for restoring a saved model value after a provider switch. Must be called on the
|
||||
* JavaFX Application Thread.
|
||||
*
|
||||
* @param value the new text field value; {@code null} is treated as an empty string
|
||||
*/
|
||||
public void setTextFieldValue(String value) {
|
||||
programmaticTextFieldSet = true;
|
||||
try {
|
||||
textField.setText(value == null ? "" : value);
|
||||
} finally {
|
||||
programmaticTextFieldSet = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX node that represents this container and can be added to the scene graph.
|
||||
*
|
||||
* @return {@code this} container; never {@code null}
|
||||
*/
|
||||
public Node asNode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies visibility to the ComboBox and TextField based on whether the list is active.
|
||||
*
|
||||
* @param listActive {@code true} to show the ComboBox, {@code false} to show the TextField
|
||||
*/
|
||||
private void applyVisibility(boolean listActive) {
|
||||
comboBox.setVisible(listActive);
|
||||
comboBox.setManaged(listActive);
|
||||
textField.setVisible(!listActive);
|
||||
textField.setManaged(!listActive);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
/**
|
||||
* Describes the origin of the currently displayed model value in the GUI model selection area.
|
||||
* <p>
|
||||
* The GUI uses this enum to decide which control to show for the model input:
|
||||
* <ul>
|
||||
* <li>When the source is {@link #LIST_REMOTE_SUCCESS}, a non-editable {@code ComboBox}
|
||||
* is shown, pre-populated with the remote list.</li>
|
||||
* <li>When the source is {@link #LIST_UNAVAILABLE_MANUAL_INPUT} or
|
||||
* {@link #LIST_FAILED_MANUAL_INPUT}, a plain text input field is shown instead,
|
||||
* allowing the user to enter the model name manually.</li>
|
||||
* <li>{@link #NOT_YET_LOADED} represents the initial state before the first retrieval
|
||||
* attempt; the GUI should render a loading indicator or show the text field
|
||||
* in a disabled/pending state.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* This enum is intentionally free of JavaFX references so it can be used in unit-tested
|
||||
* view-model code without starting a JavaFX runtime.
|
||||
*/
|
||||
public enum GuiModelSource {
|
||||
|
||||
/**
|
||||
* The model list was successfully retrieved from the remote provider endpoint.
|
||||
* <p>
|
||||
* A non-editable {@code ComboBox} is displayed, pre-selecting the first available model.
|
||||
*/
|
||||
LIST_REMOTE_SUCCESS,
|
||||
|
||||
/**
|
||||
* No model list is available because the provider does not expose a model catalogue endpoint
|
||||
* or because the configuration was incomplete.
|
||||
* <p>
|
||||
* A manual text input field is shown and the user must enter the model identifier by hand.
|
||||
*/
|
||||
LIST_UNAVAILABLE_MANUAL_INPUT,
|
||||
|
||||
/**
|
||||
* A technical error occurred while retrieving the model list (e.g., HTTP error, timeout,
|
||||
* authentication failure).
|
||||
* <p>
|
||||
* A manual text input field is shown so the user can still supply a model name; the GUI
|
||||
* also reports the failure in the central message area.
|
||||
*/
|
||||
LIST_FAILED_MANUAL_INPUT,
|
||||
|
||||
/**
|
||||
* The initial state before the first model retrieval attempt has been made.
|
||||
* <p>
|
||||
* The GUI should indicate that a retrieval is pending and must not present the manual
|
||||
* input field as the definitive fallback until at least one retrieval attempt has completed.
|
||||
*/
|
||||
NOT_YET_LOADED
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
|
||||
/**
|
||||
* Represents which provider section is currently visible in the GUI and preserves
|
||||
* the editable configuration state of the section that is currently hidden.
|
||||
* <p>
|
||||
* The GUI shows exactly one provider section at a time. When the user switches the
|
||||
* active provider, the previously visible section must not lose its field values.
|
||||
* This record captures the current display context as an immutable snapshot so that
|
||||
* view-model code can reason about visibility and data preservation without touching
|
||||
* JavaFX nodes directly.
|
||||
* <p>
|
||||
* Instances of this record contain no JavaFX references and are safe to create and
|
||||
* inspect from any thread, including unit-test threads.
|
||||
*
|
||||
* @param visibleProvider the provider family whose configuration section is
|
||||
* currently rendered; never {@code null}
|
||||
* @param visibleProviderState the editable configuration state currently displayed;
|
||||
* never {@code null}
|
||||
* @param hiddenProviderState the editable configuration state of the provider that
|
||||
* is not shown, preserved here so it is not lost on switch;
|
||||
* never {@code null}
|
||||
* @param hiddenProvider the provider family whose section is currently hidden;
|
||||
* never {@code null}
|
||||
*/
|
||||
public record GuiVisibleProviderSection(
|
||||
AiProviderFamily visibleProvider,
|
||||
GuiProviderConfigurationState visibleProviderState,
|
||||
AiProviderFamily hiddenProvider,
|
||||
GuiProviderConfigurationState hiddenProviderState) {
|
||||
|
||||
/**
|
||||
* Creates a new visible-provider section snapshot.
|
||||
*
|
||||
* @param visibleProvider provider whose section is shown; must not be {@code null}
|
||||
* @param visibleProviderState configuration state of the visible provider; must not be {@code null}
|
||||
* @param hiddenProvider provider whose section is hidden; must not be {@code null}
|
||||
* @param hiddenProviderState configuration state of the hidden provider; must not be {@code null}
|
||||
* @throws NullPointerException if any parameter is {@code null}
|
||||
* @throws IllegalArgumentException if {@code visibleProvider} and {@code hiddenProvider} are equal
|
||||
*/
|
||||
public GuiVisibleProviderSection {
|
||||
Objects.requireNonNull(visibleProvider, "visibleProvider must not be null");
|
||||
Objects.requireNonNull(visibleProviderState, "visibleProviderState must not be null");
|
||||
Objects.requireNonNull(hiddenProvider, "hiddenProvider must not be null");
|
||||
Objects.requireNonNull(hiddenProviderState, "hiddenProviderState must not be null");
|
||||
if (visibleProvider == hiddenProvider) {
|
||||
throw new IllegalArgumentException(
|
||||
"visibleProvider and hiddenProvider must be different, but both are: " + visibleProvider);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration state for the requested provider family.
|
||||
*
|
||||
* @param family the provider family to retrieve the state for; must not be {@code null}
|
||||
* @return the state for the requested provider
|
||||
* @throws IllegalArgumentException if the requested family is neither the visible nor the hidden provider
|
||||
*/
|
||||
public GuiProviderConfigurationState stateFor(AiProviderFamily family) {
|
||||
Objects.requireNonNull(family, "family must not be null");
|
||||
if (family == visibleProvider) {
|
||||
return visibleProviderState;
|
||||
}
|
||||
if (family == hiddenProvider) {
|
||||
return hiddenProviderState;
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown provider family: " + family);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the visible and hidden providers swapped, preserving both states.
|
||||
* <p>
|
||||
* The previously hidden provider becomes visible and the previously visible provider
|
||||
* moves to hidden. No field values are lost during the switch.
|
||||
*
|
||||
* @return a new section snapshot with providers and their states swapped
|
||||
*/
|
||||
public GuiVisibleProviderSection switchProvider() {
|
||||
return new GuiVisibleProviderSection(hiddenProvider, hiddenProviderState,
|
||||
visibleProvider, visibleProviderState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with a different configuration state for the visible provider.
|
||||
*
|
||||
* @param newState the updated configuration state; must not be {@code null}
|
||||
* @return a new section snapshot with the visible provider's state replaced
|
||||
*/
|
||||
public GuiVisibleProviderSection withVisibleProviderState(GuiProviderConfigurationState newState) {
|
||||
Objects.requireNonNull(newState, "newState must not be null");
|
||||
return new GuiVisibleProviderSection(visibleProvider, newState, hiddenProvider, hiddenProviderState);
|
||||
}
|
||||
}
|
||||
+35
-6
@@ -1,12 +1,41 @@
|
||||
/**
|
||||
* Editor state and template model for the JavaFX configuration editor.
|
||||
* Editor state and view-model types for the JavaFX configuration editor.
|
||||
* <p>
|
||||
* 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.
|
||||
* independently from file I/O and validation. It covers:
|
||||
* <ul>
|
||||
* <li>Loaded file snapshots, baseline editor values, current editor values and the derived
|
||||
* dirty-state view ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState},
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues}).</li>
|
||||
* <li>Provider-specific configuration state and API-key state
|
||||
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState},
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState}).</li>
|
||||
* <li>Provider section visibility and state preservation across provider switches
|
||||
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection}).</li>
|
||||
* <li>Model source classification, manual model entry, and the JavaFX model field container
|
||||
* that switches between a non-editable {@code ComboBox} and a text field depending on
|
||||
* retrieval outcome
|
||||
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource},
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiManualModelEntry},
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}).</li>
|
||||
* <li>Message severity, central message entries and field-level validation findings
|
||||
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity},
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry},
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiFieldFinding}).</li>
|
||||
* <li>The consolidated validation result that feeds both the central message area and
|
||||
* field-near error display
|
||||
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult}).</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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.
|
||||
* Most classes in this package are intentionally free of JavaFX controls so they can be used
|
||||
* in unit-tested view-model code without starting a JavaFX runtime. The exception is
|
||||
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}, which
|
||||
* extends a JavaFX {@code StackPane} and must be used only on the JavaFX Application Thread.
|
||||
* <p>
|
||||
* Types that are not GUI-specific (API-key origin provenance, model catalogue results and
|
||||
* the corresponding port contract) live in
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog} to keep the
|
||||
* Application module free of GUI dependencies while allowing future non-GUI consumers
|
||||
* to reuse these types without depending on this adapter module.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
Reference in New Issue
Block a user