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:
2026-04-20 20:31:15 +02:00
parent bbb5c4da3a
commit aa067a3165
59 changed files with 8363 additions and 136 deletions
@@ -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);
}
}
@@ -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());
}
}
@@ -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;
};
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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
}
@@ -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);
}
}
@@ -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;