V2.8: Selektive Wiederverarbeitung und Statusreset in der GUI

- Mehrfachauswahl mit CheckBox-Spalte und Master-Tri-State-Checkbox
- Gezielter Mini-Lauf über ausgewählte Einträge (unabhängig vom Status)
- Statusreset für ausgewählte Einträge (Stammsatz + Versuchshistorie)
- Fehlende Quelldatei im Mini-Lauf wird als FAILED_PERMANENT synthetisiert
- Identische Zieldatei wird als SUCCESS ohne erneute KI-Verarbeitung erkannt
- Weiche Stop-Semantik erhält zurückgesetzte Einträge unverändert
- Nicht-ausgewählte Einträge bleiben in allen Pfaden unberührt
- Buttons reagieren jetzt korrekt auf Auswahländerungen

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 12:04:22 +02:00
parent f4a1bce9ae
commit 9fd5bd5a52
40 changed files with 3478 additions and 223 deletions
@@ -296,6 +296,39 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
return stringValue != null && !stringValue.isBlank() ? Instant.parse(stringValue) : null;
}
/**
* Deletes the master record for the given fingerprint.
* <p>
* Idempotent: if no record with the given fingerprint exists the method returns
* without error. A {@link DocumentPersistenceException} is thrown only on technical
* database failures.
*
* @param fingerprint the document identity whose master record should be removed;
* must not be null
* @throws DocumentPersistenceException if the delete fails due to a technical error
*/
@Override
public void deleteByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
String sql = "DELETE FROM document_record WHERE fingerprint = ?";
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, fingerprint.sha256Hex());
int rowsAffected = statement.executeUpdate();
logger.debug("Deleted {} document_record row(s) for fingerprint: {}",
rowsAffected, fingerprint.sha256Hex());
} catch (SQLException e) {
String message = "Failed to delete document record for fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
* <p>
@@ -355,6 +355,39 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
return rs.wasNull() ? null : value;
}
/**
* Deletes all attempt history entries for the given fingerprint.
* <p>
* Idempotent: if no attempts exist for the fingerprint the method returns without
* error. A {@link DocumentPersistenceException} is thrown only on technical database
* failures.
*
* @param fingerprint the document identity whose attempt records should be removed;
* must not be null
* @throws DocumentPersistenceException if the delete fails due to a technical error
*/
@Override
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?";
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, fingerprint.sha256Hex());
int rowsAffected = statement.executeUpdate();
logger.debug("Deleted {} processing_attempt row(s) for fingerprint: {}",
rowsAffected, fingerprint.sha256Hex());
} catch (SQLException e) {
String message = "Failed to delete processing attempts for fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Returns the JDBC URL this adapter uses.
*
@@ -15,6 +15,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceExcept
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* SQLite implementation of {@link UnitOfWorkPort}.
@@ -163,5 +164,39 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
};
repo.update(record);
}
/**
* Deletes all attempt history entries and the document master record for the
* given fingerprint within the current transaction.
* <p>
* Attempts are deleted first to satisfy the foreign-key constraint between
* {@code processing_attempt} and {@code document_record}. Both deletes are
* idempotent: missing rows are silently ignored.
*
* @param fingerprint the document identity to fully reset; must not be null
* @throws DocumentPersistenceException if either delete fails
*/
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// Delete attempts first (FK constraint: processing_attempt → document_record)
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
@Override
protected Connection getConnection() throws SQLException {
return nonClosingWrapper(connection);
}
};
attemptRepo.deleteAllByFingerprint(fingerprint);
// Then delete the master record
SqliteDocumentRecordRepositoryAdapter recordRepo =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
@Override
protected Connection getConnection() throws SQLException {
return nonClosingWrapper(connection);
}
};
recordRepo.deleteByFingerprint(fingerprint);
}
}
}
@@ -1,17 +1,23 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Filesystem-based implementation of {@link TargetFolderPort}.
@@ -67,27 +73,47 @@ public class FilesystemTargetFolderAdapter implements TargetFolderPort {
}
/**
* Resolves the first available unique filename in the target folder for the given base name.
* Resolves the first available unique filename in the target folder for the given base name,
* applying an identical-content shortcut when the base name already exists.
* <p>
* Checks for {@code baseName} first; if taken, appends {@code (1)}, {@code (2)}, etc.
* directly before {@code .pdf} until a free name is found.
* Processing order:
* <ol>
* <li>If the base name does not yet exist, return it unchanged as a
* {@link ResolvedTargetFilename}.</li>
* <li>If the base name exists and its SHA-256 matches {@code sourceFingerprint},
* return {@link ExistingIdenticalTargetFile} — the target is already up-to-date.</li>
* <li>Otherwise try {@code (1)}, {@code (2)}, etc. until a free name is found and
* return it as a {@link ResolvedTargetFilename}.</li>
* </ol>
*
* @param baseName the desired filename including {@code .pdf} extension;
* must not be null or blank
* @return a {@link ResolvedTargetFilename} with the first available name, or a
* @param baseName the desired filename including {@code .pdf} extension;
* must not be null or blank
* @param sourceFingerprint the SHA-256 fingerprint of the source document; must not be null
* @return a {@link ResolvedTargetFilename}, {@link ExistingIdenticalTargetFile}, or
* {@link TargetFolderTechnicalFailure} if folder access fails
*/
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
public TargetFilenameResolutionResult resolveUniqueFilename(
String baseName, DocumentFingerprint sourceFingerprint) {
Objects.requireNonNull(baseName, "baseName must not be null");
Objects.requireNonNull(sourceFingerprint, "sourceFingerprint must not be null");
try {
Path baseNamePath = targetFolderPath.resolve(baseName);
// Try without suffix first
if (!Files.exists(targetFolderPath.resolve(baseName))) {
if (!Files.exists(baseNamePath)) {
logger.debug("Resolved target filename without suffix: '{}'", baseName);
return new ResolvedTargetFilename(baseName);
}
// The base name exists — check for identical content before adding a suffix
if (isIdenticalContent(baseNamePath, sourceFingerprint)) {
logger.debug("Target file '{}' already exists with identical content — no new copy needed.",
baseName);
return new ExistingIdenticalTargetFile(baseName);
}
// Determine split point: everything before the final ".pdf"
if (!baseName.toLowerCase().endsWith(".pdf")) {
return new TargetFolderTechnicalFailure(
@@ -115,6 +141,36 @@ public class FilesystemTargetFolderAdapter implements TargetFolderPort {
}
}
/**
* Returns {@code true} when the SHA-256 digest of the file at {@code targetPath} matches
* the hex value in {@code sourceFingerprint}.
* <p>
* Any I/O or digest error is treated as non-identical (returns {@code false} and logs
* at debug level), so the duplicate-suffix path is entered instead.
*
* @param targetPath path of the existing target file to compare
* @param sourceFingerprint expected SHA-256 hex digest of the source document
* @return {@code true} if the existing file's SHA-256 equals the source fingerprint
*/
private boolean isIdenticalContent(Path targetPath, DocumentFingerprint sourceFingerprint) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = Files.newInputStream(targetPath)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
String targetHex = HexFormat.of().formatHex(digest.digest());
return targetHex.equalsIgnoreCase(sourceFingerprint.sha256Hex());
} catch (NoSuchAlgorithmException | IOException e) {
logger.debug("Could not compute SHA-256 of existing target file '{}': {} — "
+ "treating as non-identical.", targetPath.getFileName(), e.getMessage());
return false;
}
}
/**
* Best-effort deletion of a file in the target folder.
* <p>