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
@@ -0,0 +1,106 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import java.io.IOException;
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 org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameFailureFileNotFound;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameFailureTargetExists;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenameTechnicalFailure;
/**
* Filesystem-basierte Implementierung von {@link TargetFileRenamePort}.
* <p>
* Benennt eine bestehende Datei im konfigurierten Zielordner um, indem sie
* {@link Files#move} mit {@link StandardCopyOption#ATOMIC_MOVE} verwendet. Wird
* {@link AtomicMoveNotSupportedException} geworfen (z. B. auf Netzlaufwerken), erfolgt
* ein automatischer Rückfall auf einen nicht-atomaren {@code Files.move}-Aufruf.
* <p>
* <strong>Architekturgrenze:</strong> Alle NIO-Operationen ({@code Path}, {@code Files})
* sind ausschließlich in dieser Klasse gekapselt. Der Port
* {@link TargetFileRenamePort} enthält keine Dateisystem-Typen.
*/
public class FilesystemTargetFileRenameAdapter implements TargetFileRenamePort {
private static final Logger LOG = LogManager.getLogger(FilesystemTargetFileRenameAdapter.class);
private final Path targetFolder;
/**
* Erstellt den Adapter für den angegebenen Zielordner.
*
* @param targetFolder Pfad des Zielordners; darf nicht null sein
* @throws NullPointerException wenn {@code targetFolder} null ist
*/
public FilesystemTargetFileRenameAdapter(Path targetFolder) {
this.targetFolder = Objects.requireNonNull(targetFolder, "targetFolder darf nicht null sein");
}
/**
* Benennt eine bestehende Datei im Zielordner von {@code oldFileName} zu
* {@code newFileName} um.
* <p>
* Ablauf:
* <ol>
* <li>Prüft, ob {@code oldFileName} im Zielordner vorhanden ist; falls nicht,
* wird {@link TargetFileRenameFailureFileNotFound} zurückgegeben.</li>
* <li>Prüft, ob {@code newFileName} bereits durch eine andere Datei belegt ist;
* falls ja, wird {@link TargetFileRenameFailureTargetExists} zurückgegeben.</li>
* <li>Versucht {@link StandardCopyOption#ATOMIC_MOVE}; bei
* {@link AtomicMoveNotSupportedException} (z. B. Netzlaufwerk) erfolgt ein
* Rückfall auf einen normalen Verschiebeaufruf ohne Atomic-Flag.</li>
* <li>Bei Erfolg: {@link TargetFileRenameSuccess}.</li>
* <li>Bei anderen {@link IOException}: {@link TargetFileRenameTechnicalFailure}
* mit deutschem Fehlertext.</li>
* </ol>
*
* @param oldFileName der aktuell im Zielordner vorhandene Dateiname (ohne Pfad);
* darf nicht null sein
* @param newFileName der gewünschte neue Dateiname (ohne Pfad); darf nicht null sein
* @return das Ergebnis der Umbenennung; nie null
*/
@Override
public TargetFileRenameResult rename(String oldFileName, String newFileName) {
Objects.requireNonNull(oldFileName, "oldFileName darf nicht null sein");
Objects.requireNonNull(newFileName, "newFileName darf nicht null sein");
Path oldPath = targetFolder.resolve(oldFileName);
Path newPath = targetFolder.resolve(newFileName);
if (Files.notExists(oldPath)) {
LOG.warn("Umbenennung verweigert: Quelldatei nicht vorhanden: '{}'", oldPath);
return new TargetFileRenameFailureFileNotFound(oldFileName);
}
if (Files.exists(newPath) && !oldPath.equals(newPath)) {
LOG.warn("Umbenennung verweigert: Zieldatei bereits vorhanden: '{}'", newPath);
return new TargetFileRenameFailureTargetExists(newFileName);
}
try {
try {
Files.move(oldPath, newPath, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException atomicEx) {
LOG.warn("Atomares Verschieben nicht unterstützt (z. B. Netzlaufwerk) für '{}' → '{}'. " +
"Rückfall auf normales Verschieben.", oldPath, newPath);
Files.move(oldPath, newPath);
}
LOG.info("Datei erfolgreich umbenannt: '{}' → '{}'", oldFileName, newFileName);
return new TargetFileRenameSuccess();
} catch (IOException e) {
String message = "Technischer Fehler beim Umbenennen von '" + oldFileName
+ "' zu '" + newFileName + "': " + e.getMessage();
LOG.error(message, e);
return new TargetFileRenameTechnicalFailure(message);
}
}
}
@@ -6,6 +6,9 @@
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter}
* — Filesystem-based implementation of
* {@link de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort}.</li>
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFileRenameAdapter}
* — Filesystem-based implementation of
* {@link de.gecheckt.pdf.umbenenner.application.port.out.TargetFileRenamePort}.</li>
* </ul>
* <p>
* <strong>Duplicate resolution:</strong> Given a base name such as