V2.9: Integrierte PDF-Vorschau und editierbarer Dateiname im Verarbeitungslauf

Neu im Tab "Verarbeitungslauf":
- Integrierte PDF-Vorschau der Quelldatei mit Lazy Rendering (Seite 1 sofort,
  weitere Seiten on-demand), Cache pro Selektion, "latest preview request wins"
- Editierbarer KI-Dateinamenvorschlag mit Live-Validierung, Dirty-State-Dialog
  bei Zeilen-/Tabwechsel, Schließen und Laufstart, atomare FS+DB-Transaktion
  inkl. Rollback und Fingerprint-basierter Konfliktauflösung

Architektur:
- Neuer Application-Use-Case ManualFileRenameUseCase und Outbound-Port
  TargetFileRenamePort mit Filesystem-Adapter
- Neuer GuiManualFileRenamePort, verdrahtet im Bootstrap
- GuiBatchRunResultRow um correctedFileName erweitert
- GuiBatchRunTab auf SplitPane-Layout (60/40) umgebaut, Detail-Panel mit
  KI-Begründung, FileNameEditorPane und PdfPreviewPane
- Spike-Code (PdfViewerSpike) entfernt, produktive Implementierung ersetzt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 12:30:55 +02:00
parent f6b265b370
commit d3fbfc4094
34 changed files with 3823 additions and 188 deletions
@@ -24,6 +24,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
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;
@@ -46,6 +47,7 @@ import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepo
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFileRenameAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
@@ -53,6 +55,9 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfigurat
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
@@ -67,6 +72,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
@@ -74,6 +80,7 @@ import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
@@ -675,6 +682,7 @@ public class BootstrapRunner {
this::launchGuiMiniBatchRun;
de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort =
this::resetDocumentStatusForGui;
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
if (configPathOverride.isEmpty()) {
return new GuiStartupContext(
@@ -690,7 +698,8 @@ public class BootstrapRunner {
correctionExecutionService,
batchRunLauncher,
miniRunLauncher,
resetPort);
resetPort,
manualRenamePort);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -711,7 +720,8 @@ public class BootstrapRunner {
correctionExecutionService,
batchRunLauncher,
miniRunLauncher,
resetPort);
resetPort,
manualRenamePort);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -720,7 +730,7 @@ public class BootstrapRunner {
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetPort);
miniRunLauncher, resetPort, manualRenamePort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -737,7 +747,8 @@ public class BootstrapRunner {
correctionExecutionService,
batchRunLauncher,
miniRunLauncher,
resetPort);
resetPort,
manualRenamePort);
}
}
@@ -954,6 +965,103 @@ public class BootstrapRunner {
}
}
/**
* Erstellt einen vollständig verdrahteten {@link ManualFileRenameUseCase} für den
* gegebenen Startkonfigurations-Stand.
* <p>
* Teilt die Wiring-Konventionen mit dem Batch-Pfad: SQLite-URL-Aufbau, Adapter-Instanzen
* und Logger-Konfiguration werden nach dem gleichen Muster erzeugt.
*
* @param startConfig die validierte Startkonfiguration; darf nicht null sein
* @return ein einsatzbereiter Use-Case; nie null
*/
private ManualFileRenameUseCase buildProductionManualFileRenameUseCase(
StartConfiguration startConfig) {
String jdbcUrl = buildJdbcUrl(startConfig);
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
TargetFolderPort targetFolderPort =
new FilesystemTargetFolderAdapter(startConfig.targetFolder());
TargetFileRenamePort targetFileRenamePort =
new FilesystemTargetFileRenameAdapter(startConfig.targetFolder());
ClockPort clockPort = new SystemClockAdapter();
AiContentSensitivity aiContentSensitivity =
resolveAiContentSensitivity(startConfig.logAiSensitive());
ProcessingLogger processingLogger = new Log4jProcessingLogger(
DefaultManualFileRenameUseCase.class, aiContentSensitivity);
return new DefaultManualFileRenameUseCase(
documentRecordRepository,
targetFolderPort,
targetFileRenamePort,
unitOfWorkPort,
clockPort,
processingLogger);
}
/**
* Führt eine manuelle Umbenennung einer Zieldatei durch, ausgelöst von der GUI.
* <p>
* Lädt und validiert die Konfiguration aus {@code configFilePath}, baut den
* Use-Case auf und delegiert die Umbenennung. Alle Fehler beim Laden oder
* Validieren der Konfiguration werden als strukturiertes {@link ManualFileRenameResult}
* zurückgegeben.
*
* @param configFilePath Pfad zur {@code .properties}-Datei; muss existieren
* @param request die Umbenennungsanfrage; darf nicht null sein
* @return das Ergebnis der Umbenennung; nie null
*/
ManualFileRenameResult performGuiManualFileRename(
Path configFilePath,
ManualFileRenameRequest request) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
Objects.requireNonNull(request, "request must not be null");
LOG.info("GUI-Umbenennung: Anfrage für Fingerprint={}, Zielname={}.",
request.fingerprint().sha256Hex(), request.desiredBaseFileName());
if (!Files.exists(configFilePath)) {
String msg = "Konfigurationsdatei nicht gefunden: " + configFilePath;
LOG.error("GUI-Umbenennung: {}", msg);
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileRenameFileSystemFailure(msg);
}
try {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
ManualFileRenameUseCase useCase = buildProductionManualFileRenameUseCase(config);
ManualFileRenameResult result = useCase.rename(request);
LOG.info("GUI-Umbenennung abgeschlossen: Ergebnis={}.", result.getClass().getSimpleName());
return result;
} catch (ConfigurationLoadingException e) {
LOG.error("GUI-Umbenennung: Konfiguration konnte nicht geladen werden: {}",
e.getMessage(), e);
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileRenamePersistenceFailure(
"Konfiguration konnte nicht geladen werden: " + e.getMessage());
} catch (InvalidStartConfigurationException e) {
LOG.error("GUI-Umbenennung: Konfiguration ist nicht lauffähig: {}", e.getMessage());
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileRenamePersistenceFailure(
"Die Konfiguration ist nicht lauffähig: " + e.getMessage());
} catch (DocumentPersistenceException e) {
LOG.error("GUI-Umbenennung: SQLite-Initialisierung fehlgeschlagen: {}",
e.getMessage(), e);
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileRenamePersistenceFailure(
"SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
} catch (RuntimeException e) {
LOG.error("GUI-Umbenennung: Unerwarteter Fehler: {}", e.getMessage(), e);
return new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileRenameFileSystemFailure(
"Unerwarteter Fehler: "
+ (e.getMessage() == null
? e.getClass().getSimpleName()
: e.getMessage()));
}
}
/**
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
* recorded as a failure with the given error message.