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:
+33
@@ -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>
|
||||
|
||||
+33
@@ -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.
|
||||
*
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-8
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user