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

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

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:51:13 +02:00
parent 6d4654f482
commit bbb5c4da3a
22 changed files with 5221 additions and 37 deletions
@@ -16,10 +16,11 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationEditorWorkspace;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
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.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
@@ -614,12 +615,14 @@ public class BootstrapRunner {
private GuiStartupContext buildGuiStartupContext(Optional<String> configPathOverride) {
GuiConfigurationFileLoader loader = this::loadGuiConfigurationState;
GuiConfigurationFileWriter writer = new GuiConfigurationPropertiesWriter();
if (configPathOverride.isEmpty()) {
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.empty(),
loader);
loader,
writer);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -630,20 +633,22 @@ public class BootstrapRunner {
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.of("Konfigurationsdatei nicht gefunden: " + configPath.toAbsolutePath()
+ "\nDie GUI startet ohne Konfigurationsdatei."),
loader);
loader,
writer);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
try {
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
return new GuiStartupContext(loadedState, Optional.empty(), loader);
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.of("Konfiguration konnte nicht geladen werden: " + e.getMessage()),
loader);
loader,
writer);
}
}
@@ -0,0 +1,225 @@
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationSaveResult;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationWriteException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
/**
* Writes a normalized {@code .properties} file from the current GUI editor values.
* <p>
* This adapter implements the {@link GuiConfigurationFileWriter} port and is wired by
* Bootstrap. It performs two main responsibilities:
* <ol>
* <li>Creates a {@code .bak} backup of any existing file before overwriting it, using
* the same rotation schema as the legacy configuration migrator:
* {@code <filename>.bak}, and on collision {@code .bak.1}, {@code .bak.2}, …
* Existing backups are never overwritten.</li>
* <li>Writes the editor values as a canonically ordered, grouped and commented
* {@code .properties} file via a temporary file and an atomic rename, so the
* existing file is never partially overwritten.</li>
* </ol>
* <p>
* API-key preservation logic (detecting empty fields with a non-empty baseline) is handled
* by the caller (workspace) before invoking this writer. The writer simply serializes the
* values it receives.
*
* <h2>Normalized output order</h2>
* <pre>{@code
* # Provider
* ai.provider.active=...
* # Claude
* ai.provider.claude.*
* # OpenAI-kompatibel
* ai.provider.openai-compatible.*
* # Pfade
* source.folder=...
* target.folder=...
* sqlite.file=...
* # Verarbeitung
* max.retries.transient=...
* max.pages=...
* max.text.characters=...
* prompt.template.file=...
* # Logging
* log.ai.sensitive=...
* log.directory=...
* log.level=...
* # Laufzeit
* runtime.lock.file=...
* }</pre>
*/
public final class GuiConfigurationPropertiesWriter implements GuiConfigurationFileWriter {
private static final Logger LOG = LogManager.getLogger(GuiConfigurationPropertiesWriter.class);
/**
* Creates a new properties writer.
*/
public GuiConfigurationPropertiesWriter() {
}
/**
* Writes the editor values to the target path as a normalized {@code .properties} file.
* <p>
* When the target file already exists, a backup is created before the file is overwritten.
* The write is performed via a temporary file followed by an atomic rename.
*
* <p><strong>Threading contract:</strong> This method performs blocking file-system I/O
* ({@link java.nio.file.Files#exists}, backup copy, directory creation, file write, atomic
* move). It must be invoked from a background worker thread. It must never be called from
* the JavaFX Application Thread.
*
* @param values the current editor values; must not be {@code null}
* @param targetPath the file to write; must not be {@code null}
* @return the save result containing the written path
* @throws GuiConfigurationWriteException if the file cannot be written
*/
@Override
public GuiConfigurationSaveResult write(GuiConfigurationValues values, Path targetPath) {
if (Files.exists(targetPath)) {
createBakBackup(targetPath);
}
String content = buildPropertiesContent(values);
writeAtomically(targetPath, content);
LOG.info("Konfigurationsdatei geschrieben: {}", targetPath.toAbsolutePath());
return GuiConfigurationSaveResult.saved(targetPath);
}
/**
* Creates a rotating backup of the file at the given path.
* <p>
* The first backup uses the suffix {@code .bak}. When that file already exists,
* numbered suffixes are tried in ascending order ({@code .bak.1}, {@code .bak.2}, …)
* until a free slot is found. Existing backups are never overwritten.
*
* @param targetPath the file to back up; must exist
* @throws GuiConfigurationWriteException if the backup cannot be created
*/
void createBakBackup(Path targetPath) {
Path bakPath = targetPath.resolveSibling(targetPath.getFileName() + ".bak");
if (!Files.exists(bakPath)) {
copyFile(targetPath, bakPath);
LOG.info("Sicherungskopie erstellt: {}", bakPath);
return;
}
for (int i = 1; ; i++) {
Path numbered = targetPath.resolveSibling(targetPath.getFileName() + ".bak." + i);
if (!Files.exists(numbered)) {
copyFile(targetPath, numbered);
LOG.info("Sicherungskopie erstellt: {}", numbered);
return;
}
}
}
/**
* Writes the content to the target path via a temporary file and an atomic rename.
*
* @param target the destination path; must not be {@code null}
* @param content the content to write; must not be {@code null}
* @throws GuiConfigurationWriteException if the file cannot be written
*/
private void writeAtomically(Path target, String content) {
Path tmpPath = target.resolveSibling(target.getFileName() + ".tmp");
try {
Path parentDir = target.getParent();
if (parentDir != null) {
Files.createDirectories(parentDir);
}
Files.writeString(tmpPath, content, StandardCharsets.UTF_8);
Files.move(tmpPath, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new GuiConfigurationWriteException(
"Konfigurationsdatei konnte nicht geschrieben werden: " + target, e);
}
}
private void copyFile(Path source, Path destination) {
try {
Files.copy(source, destination);
} catch (IOException e) {
throw new GuiConfigurationWriteException(
"Sicherungskopie konnte nicht erstellt werden: " + destination, e);
}
}
/**
* Builds the normalized {@code .properties} file content from the given values.
*
* @param values the values to serialize; must not be {@code null}
* @return the complete file content as a UTF-8 string
*/
String buildPropertiesContent(GuiConfigurationValues values) {
StringBuilder sb = new StringBuilder();
appendLine(sb, "# Aktiver KI-Provider (claude oder openai-compatible)");
appendKeyValue(sb, "ai.provider.active", values.activeProviderFamily());
appendLine(sb, "");
appendLine(sb, "# Provider-Konfiguration: Claude");
GuiProviderConfigurationState claude = values.providerConfiguration(AiProviderFamily.CLAUDE);
if (claude != null) {
appendKeyValue(sb, "ai.provider.claude.baseUrl", claude.baseUrl());
appendKeyValue(sb, "ai.provider.claude.model", claude.model());
appendKeyValue(sb, "ai.provider.claude.timeoutSeconds", claude.timeoutSeconds());
appendKeyValue(sb, "ai.provider.claude.apiKey", claude.apiKey().propertyValue());
}
appendLine(sb, "");
appendLine(sb, "# Provider-Konfiguration: OpenAI-kompatibel");
GuiProviderConfigurationState openai = values.providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE);
if (openai != null) {
appendKeyValue(sb, "ai.provider.openai-compatible.baseUrl", openai.baseUrl());
appendKeyValue(sb, "ai.provider.openai-compatible.model", openai.model());
appendKeyValue(sb, "ai.provider.openai-compatible.timeoutSeconds", openai.timeoutSeconds());
appendKeyValue(sb, "ai.provider.openai-compatible.apiKey", openai.apiKey().propertyValue());
}
appendLine(sb, "");
appendLine(sb, "# Pfade");
appendKeyValue(sb, "source.folder", values.sourceFolder());
appendKeyValue(sb, "target.folder", values.targetFolder());
appendKeyValue(sb, "sqlite.file", values.sqliteFile());
appendLine(sb, "");
appendLine(sb, "# Verarbeitung");
appendKeyValue(sb, "max.retries.transient", values.maxRetriesTransient());
appendKeyValue(sb, "max.pages", values.maxPages());
appendKeyValue(sb, "max.text.characters", values.maxTextCharacters());
appendKeyValue(sb, "prompt.template.file", values.promptTemplateFile());
appendLine(sb, "");
appendLine(sb, "# Logging");
appendKeyValue(sb, "log.ai.sensitive", values.logAiSensitive());
appendKeyValue(sb, "log.directory", values.logDirectory());
appendKeyValue(sb, "log.level", values.logLevel());
appendLine(sb, "");
appendLine(sb, "# Laufzeit");
appendKeyValue(sb, "runtime.lock.file", values.runtimeLockFile());
return sb.toString();
}
private static void appendLine(StringBuilder sb, String line) {
sb.append(line).append("\n");
}
private static void appendKeyValue(StringBuilder sb, String key, String value) {
sb.append(key).append("=").append(value == null ? "" : value).append("\n");
}
}
@@ -0,0 +1,9 @@
/**
* Technical adapters wired exclusively by the Bootstrap module.
* <p>
* This package contains adapter implementations that are not part of any other module's
* public contract. They are instantiated and wired by Bootstrap and injected into the
* appropriate ports. Adapter classes in this package may depend on both inbound and
* outbound module contracts, but must not introduce circular dependencies.
*/
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;