#71: Prompt-Editor-Tab in der GUI implementieren
Neuer Tab „Prompt" in der GUI-Hauptansicht ermöglicht das Lesen, Bearbeiten und atomare Speichern der konfigurierten KI-Prompt-Datei ohne externen Editor. Änderungen: - PromptSaveResult: neue sealed interface mit Saved, WriteFailed, TargetDirectoryMissing, AtomicMoveFailed als strukturierte Ergebnistypen für savePrompt() - PromptPort: um savePrompt(String) erweitert (nicht mehr funktional – Teststubs angepasst) - FilesystemPromptPortAdapter: savePrompt() mit Temp-Datei im selben Verzeichnis + ATOMIC_MOVE, kein stiller Fallback bei AtomicMoveNotSupportedException - DefaultPromptEditorUseCase: Use-Case-Klasse mit loadPrompt(), savePrompt(), createDefaultPromptIfMissing() als Delegation an PromptPort und ResourceCreationPort - GuiPromptEditorPort: GUI-internes Bridge-Interface (kein hexagonaler Port) - GuiPromptEditorTab: JavaFX-Tab mit TextArea, Dirty-State-Tracking, Speichern/Reset/Anlegen, injizierbare threadFactory + fxDispatcher für Testbarkeit - GuiStartupContext: um promptEditorPort erweitert; alle Backward-Compat-Konstruktoren und blank() mit noOpPromptEditorPort() versorgt - GuiConfigurationEditorWorkspace: promptEditorTab integriert, Tab-Wechsel-Schutz erweitert - BootstrapRunner: buildGuiPromptEditorPort() verdrahtet FilesystemPromptPortAdapter + DefaultPromptEditorUseCase; noOpGuiPromptEditorPort() für Blank-Start-Fälle - Tests: DefaultPromptEditorUseCaseTest, FilesystemPromptPortAdapterTest (savePrompt), GuiPromptEditorTabSmokeTest (headless Monocle), GuiAdapterSmokeTest auf 3 Tabs aktualisiert - docs/betrieb.md: Prompt-Tab dokumentiert, Pfad-Auflösungstabelle ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+113
-23
@@ -2,9 +2,12 @@ package de.gecheckt.pdf.umbenenner.adapter.out.prompt;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -13,28 +16,36 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||
|
||||
/**
|
||||
* Filesystem-based implementation of {@link PromptPort}.
|
||||
* Dateisystembasierte Implementierung von {@link PromptPort}.
|
||||
* <p>
|
||||
* Loads prompt templates from an external file on disk and derives a stable identifier
|
||||
* from the filename. Ensures that empty or technically unusable prompts are rejected.
|
||||
* Lädt Prompt-Templates aus einer externen Datei auf dem Datenträger und leitet einen
|
||||
* stabilen Identifikator aus dem Dateinamen ab. Stellt sicher, dass leere oder technisch
|
||||
* unbrauchbare Prompts abgelehnt werden.
|
||||
* <p>
|
||||
* <strong>Identifier derivation:</strong>
|
||||
* The stable prompt identifier is derived from the filename of the prompt file.
|
||||
* This ensures deterministic, reproducible identification across batch runs.
|
||||
* For example, a prompt file named {@code "prompt_de_v2.txt"} receives the identifier
|
||||
* <strong>Identifikatorableitung:</strong>
|
||||
* Der stabile Identifikator wird aus dem Dateinamen der Prompt-Datei abgeleitet.
|
||||
* Eine Prompt-Datei namens {@code "prompt_de_v2.txt"} erhält den Identifikator
|
||||
* {@code "prompt_de_v2.txt"}.
|
||||
* <p>
|
||||
* <strong>Content validation:</strong>
|
||||
* After loading, the prompt content is trimmed and validated to ensure it is not empty.
|
||||
* An empty prompt (or one containing only whitespace) is considered technically unusable
|
||||
* and results in a {@link PromptLoadingFailure}.
|
||||
* <strong>Inhaltsprüfung:</strong>
|
||||
* Nach dem Laden wird der Inhalt getrimmt und auf Leerheit geprüft. Ein leerer Prompt
|
||||
* (oder einer, der nur Leerzeichen enthält) gilt als technisch unbrauchbar und führt zu
|
||||
* {@link PromptLoadingFailure}.
|
||||
* <p>
|
||||
* <strong>Error handling:</strong>
|
||||
* All technical failures (file not found, I/O errors, permission issues) are caught
|
||||
* and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
|
||||
* <strong>Atomares Speichern:</strong>
|
||||
* {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei <em>im selben
|
||||
* Verzeichnis</em> wie die Zieldatei und verschiebt diese danach atomar via
|
||||
* {@code ATOMIC_MOVE}. Bei einem Fehler beim atomaren Verschieben wird kein stiller
|
||||
* Fallback auf nicht-atomares Schreiben durchgeführt.
|
||||
* <p>
|
||||
* <strong>Fehlerbehandlung:</strong>
|
||||
* Alle technischen Fehler (Datei nicht gefunden, I/O-Fehler, fehlende Berechtigungen)
|
||||
* werden abgefangen und als strukturierte Ergebnistypen zurückgegeben – keine Exceptions
|
||||
* werden propagiert.
|
||||
*/
|
||||
public class FilesystemPromptPortAdapter implements PromptPort {
|
||||
|
||||
@@ -43,15 +54,21 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
||||
private final Path promptFilePath;
|
||||
|
||||
/**
|
||||
* Creates the adapter with the configured prompt file path.
|
||||
* Erstellt den Adapter mit dem konfigurierten Pfad zur Prompt-Datei.
|
||||
*
|
||||
* @param promptFilePath the path to the prompt template file; must not be null
|
||||
* @throws NullPointerException if promptFilePath is null
|
||||
* @param promptFilePath Pfad zur Prompt-Template-Datei; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code promptFilePath} null ist
|
||||
*/
|
||||
public FilesystemPromptPortAdapter(Path promptFilePath) {
|
||||
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das konfigurierte Prompt-Template aus der Datei.
|
||||
*
|
||||
* @return {@link PromptLoadingResult} mit dem geladenen Inhalt oder einem klassifizierten Fehler;
|
||||
* nie {@code null}
|
||||
*/
|
||||
@Override
|
||||
public PromptLoadingResult loadPrompt() {
|
||||
try {
|
||||
@@ -71,11 +88,11 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
||||
}
|
||||
|
||||
PromptIdentifier identifier = deriveIdentifier();
|
||||
LOG.debug("Prompt loaded successfully from {}", promptFilePath);
|
||||
LOG.debug("Prompt erfolgreich geladen von {}", promptFilePath);
|
||||
return new PromptLoadingSuccess(identifier, trimmedContent);
|
||||
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load prompt file: {}", promptFilePath, e);
|
||||
LOG.error("Fehler beim Laden der Prompt-Datei: {}", promptFilePath, e);
|
||||
return new PromptLoadingFailure(
|
||||
"IO_ERROR",
|
||||
"Failed to read prompt file: " + e.getMessage());
|
||||
@@ -83,15 +100,88 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable prompt identifier from the filename.
|
||||
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
|
||||
* <p>
|
||||
* The identifier is simply the filename (without the directory path).
|
||||
* This ensures that the same prompt file always receives the same identifier.
|
||||
* Der Ablauf:
|
||||
* <ol>
|
||||
* <li>Prüfen, ob der Zielordner existiert.</li>
|
||||
* <li>Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen.</li>
|
||||
* <li>Inhalt in UTF-8 in die temporäre Datei schreiben.</li>
|
||||
* <li>Temporäre Datei via {@code ATOMIC_MOVE} zur Zieldatei verschieben.</li>
|
||||
* <li>Bei Fehler: temporäre Datei aufräumen, Fehler als Ergebnis zurückgeben.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Zeilenenden werden unverändert übernommen. Es findet keine Normalisierung statt.
|
||||
*
|
||||
* @return a stable PromptIdentifier based on the filename
|
||||
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
|
||||
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code content} null ist
|
||||
*/
|
||||
@Override
|
||||
public PromptSaveResult savePrompt(String content) {
|
||||
Objects.requireNonNull(content, "content must not be null");
|
||||
|
||||
Path targetDir = promptFilePath.getParent();
|
||||
if (targetDir == null || !Files.isDirectory(targetDir)) {
|
||||
String message = "Zielordner der Prompt-Datei existiert nicht: "
|
||||
+ (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
|
||||
LOG.warn("Prompt speichern fehlgeschlagen: {}", message);
|
||||
return new PromptSaveResult.TargetDirectoryMissing(message);
|
||||
}
|
||||
|
||||
// Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen
|
||||
// (nicht im System-Temp – ATOMIC_MOVE funktioniert nicht zuverlässig über Dateisystem-Grenzen)
|
||||
Path tempFile = targetDir.resolve(".prompt-tmp-" + UUID.randomUUID() + ".tmp");
|
||||
|
||||
try {
|
||||
Files.write(tempFile, content.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
beräumeTempDatei(tempFile);
|
||||
String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
|
||||
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
|
||||
return new PromptSaveResult.WriteFailed(message, e);
|
||||
}
|
||||
|
||||
// Atomares Verschieben – kein stiller Fallback auf nicht-atomares Move
|
||||
try {
|
||||
Files.move(tempFile, promptFilePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
LOG.info("Prompt-Datei erfolgreich gespeichert: {}", promptFilePath.toAbsolutePath());
|
||||
return new PromptSaveResult.Saved(promptFilePath.toAbsolutePath().toString());
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
beräumeTempDatei(tempFile);
|
||||
String message = "Atomares Verschieben der Prompt-Datei wird vom Dateisystem nicht unterstützt: " + e.getMessage();
|
||||
LOG.warn("Prompt speichern fehlgeschlagen (kein Fallback): {}", message, e);
|
||||
return new PromptSaveResult.AtomicMoveFailed(message);
|
||||
} catch (IOException e) {
|
||||
beräumeTempDatei(tempFile);
|
||||
String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
|
||||
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
|
||||
return new PromptSaveResult.AtomicMoveFailed(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet den stabilen Prompt-Identifikator aus dem Dateinamen ab.
|
||||
* <p>
|
||||
* Der Identifikator entspricht dem Dateinamen ohne Verzeichnispfad.
|
||||
*
|
||||
* @return stabiler {@link PromptIdentifier} basierend auf dem Dateinamen
|
||||
*/
|
||||
private PromptIdentifier deriveIdentifier() {
|
||||
String filename = promptFilePath.getFileName().toString();
|
||||
return new PromptIdentifier(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht, die temporäre Datei zu löschen. Fehler werden nur geloggt.
|
||||
*
|
||||
* @param tempFile die zu löschende temporäre Datei
|
||||
*/
|
||||
private void beräumeTempDatei(Path tempFile) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException ex) {
|
||||
LOG.warn("Temporäre Prompt-Datei konnte nicht gelöscht werden: {}", tempFile, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+132
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link FilesystemPromptPortAdapter}.
|
||||
@@ -199,4 +200,135 @@ class FilesystemPromptPortAdapterTest {
|
||||
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
|
||||
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// savePrompt tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldReturnSaved_whenTargetDirExistsAndWriteSucceeds() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_save.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String content = "Mein Prompt-Inhalt";
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt(content);
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
PromptSaveResult.Saved saved = (PromptSaveResult.Saved) result;
|
||||
assertThat(saved.absolutePath()).contains("prompt_save.txt");
|
||||
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldPreserveUtf8Content_includingUmlauts() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_umlaut.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String content = "Ärger mit Überschriften und Schluß";
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt(content);
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldPreserveLineEndings_withoutNormalization() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_lineendings.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String content = "Zeile 1\r\nZeile 2\nZeile 3\r\n";
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt(content);
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
byte[] raw = Files.readAllBytes(promptFile);
|
||||
assertThat(new String(raw, StandardCharsets.UTF_8)).isEqualTo(content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldOverwriteExistingFile_atomically() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_overwrite.txt");
|
||||
Files.writeString(promptFile, "Alter Inhalt", StandardCharsets.UTF_8);
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String newContent = "Neuer Inhalt";
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt(newContent);
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(newContent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldReturnTargetDirectoryMissing_whenDirectoryDoesNotExist() {
|
||||
// Given
|
||||
Path nonExistentDir = tempDir.resolve("missing-subdir");
|
||||
Path promptFile = nonExistentDir.resolve("prompt.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt("Inhalt");
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
|
||||
PromptSaveResult.TargetDirectoryMissing missing = (PromptSaveResult.TargetDirectoryMissing) result;
|
||||
assertThat(missing.message()).contains("missing-subdir");
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldThrowNullPointerException_whenContentIsNull() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_null.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
|
||||
// When & Then
|
||||
assertThatThrownBy(() -> adapter.savePrompt(null))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessage("content must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_shouldLeaveDirClean_whenTargetDirectoryIsMissing() {
|
||||
// Given – Verzeichnis existiert nicht; keine Temp-Datei soll zurückbleiben
|
||||
Path nonExistentDir = tempDir.resolve("ghost-dir");
|
||||
Path promptFile = nonExistentDir.resolve("prompt.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
|
||||
// When
|
||||
PromptSaveResult result = adapter.savePrompt("Inhalt");
|
||||
|
||||
// Then
|
||||
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
|
||||
// Verzeichnis wurde nicht angelegt (da Directory-Check fehlschlug)
|
||||
assertThat(nonExistentDir).doesNotExist();
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePrompt_roundTrip_loadAfterSaveReturnsSameContent() throws IOException {
|
||||
// Given
|
||||
Path promptFile = tempDir.resolve("prompt_roundtrip.txt");
|
||||
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||
String content = "Runde-Trip-Inhalt\nMit mehreren Zeilen.";
|
||||
|
||||
// When
|
||||
PromptSaveResult saveResult = adapter.savePrompt(content);
|
||||
PromptLoadingResult loadResult = adapter.loadPrompt();
|
||||
|
||||
// Then
|
||||
assertThat(saveResult).isInstanceOf(PromptSaveResult.Saved.class);
|
||||
assertThat(loadResult).isInstanceOf(PromptLoadingSuccess.class);
|
||||
PromptLoadingSuccess success = (PromptLoadingSuccess) loadResult;
|
||||
// loadPrompt trims the content; trim the expected too
|
||||
assertThat(success.promptContent()).isEqualTo(content.trim());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user