#7: Historien-Tab mit Liste, Detail, Filter, Status-Reset und Eintrag-Loeschen
Implementiert den vollstaendigen Historien-Tab (Verlauf) als vierten Tab der GUI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+409
@@ -0,0 +1,409 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||
|
||||
/**
|
||||
* SQLite-Implementierung von {@link HistoryQueryPort}.
|
||||
* <p>
|
||||
* Kapselt alle lesenden Datenbankoperationen für den Historien-Tab.
|
||||
* Sämtliche JDBC-Details sind strikt in dieser Klasse eingeschlossen;
|
||||
* keine JDBC-Typen erscheinen im Port-Interface oder in Domänen-/Application-Typen.
|
||||
* <p>
|
||||
* <strong>Suche:</strong> Freitextsuche ist case-insensitiv (via {@code LOWER()}).
|
||||
* Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem
|
||||
* SQL-LIKE-Aufruf mit {@code \} escaped.
|
||||
* <p>
|
||||
* <strong>Sortierung:</strong> Standard absteigend nach {@code updated_at},
|
||||
* Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar).
|
||||
* <p>
|
||||
* <strong>Limit:</strong> Wird direkt als SQL-{@code LIMIT} angewendet.
|
||||
* Ein Limit von 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr
|
||||
* als 500 Treffer vorhanden sind.
|
||||
*/
|
||||
public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(SqliteHistoryQueryAdapter.class);
|
||||
|
||||
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
/**
|
||||
* Erzeugt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
|
||||
*
|
||||
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
|
||||
* @throws NullPointerException wenn {@code jdbcUrl} null ist
|
||||
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
|
||||
*/
|
||||
public SqliteHistoryQueryAdapter(String jdbcUrl) {
|
||||
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
|
||||
if (jdbcUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
|
||||
}
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* <p>
|
||||
* Die SQL-Abfrage aggregiert die Versuchsanzahl per {@code COUNT}-Subquery.
|
||||
* Freitextsuche und Status-Filter werden als optionale WHERE-Klauseln ergänzt.
|
||||
*
|
||||
* @param query Abfrageparameter; darf nicht {@code null} sein
|
||||
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
*/
|
||||
@Override
|
||||
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||
Objects.requireNonNull(query, "query darf nicht null sein");
|
||||
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT
|
||||
dr.fingerprint,
|
||||
dr.overall_status,
|
||||
dr.last_known_source_file_name,
|
||||
dr.last_target_file_name,
|
||||
dr.last_known_source_locator,
|
||||
dr.updated_at,
|
||||
(SELECT COUNT(*) FROM processing_attempt pa WHERE pa.fingerprint = dr.fingerprint) AS attempt_count
|
||||
FROM document_record dr
|
||||
WHERE 1=1
|
||||
""");
|
||||
|
||||
List<Object> params = new ArrayList<>();
|
||||
|
||||
// Freitextsuche: case-insensitiv über Quelldateiname und Zieldateiname
|
||||
String searchText = query.searchText();
|
||||
if (searchText != null && !searchText.isBlank()) {
|
||||
String escaped = escapeSqlLike(searchText.strip().toLowerCase());
|
||||
sql.append(" AND (LOWER(dr.last_known_source_file_name) LIKE ? ESCAPE '\\' "
|
||||
+ "OR LOWER(dr.last_target_file_name) LIKE ? ESCAPE '\\')");
|
||||
String pattern = "%" + escaped + "%";
|
||||
params.add(pattern);
|
||||
params.add(pattern);
|
||||
}
|
||||
|
||||
// Status-Filter
|
||||
String statusFilter = query.statusFilter();
|
||||
if (statusFilter != null && !statusFilter.isBlank()) {
|
||||
sql.append(" AND dr.overall_status = ?");
|
||||
params.add(statusFilter.strip());
|
||||
}
|
||||
|
||||
sql.append(" ORDER BY dr.updated_at DESC, dr.fingerprint ASC");
|
||||
sql.append(" LIMIT ?");
|
||||
params.add(query.limit());
|
||||
|
||||
try (Connection connection = getConnection();
|
||||
Statement pragmaStmt = connection.createStatement();
|
||||
PreparedStatement stmt = connection.prepareStatement(sql.toString())) {
|
||||
|
||||
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||
|
||||
for (int i = 0; i < params.size(); i++) {
|
||||
stmt.setObject(i + 1, params.get(i));
|
||||
}
|
||||
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
List<DocumentHistoryRow> rows = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
rows.add(mapToDocumentHistoryRow(rs));
|
||||
}
|
||||
logger.debug("Historien-Übersicht geladen: {} Zeilen (Limit {})", rows.size(), query.limit());
|
||||
return List.copyOf(rows);
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Historien-Übersicht konnte nicht geladen werden: " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
*/
|
||||
@Override
|
||||
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
last_known_source_locator,
|
||||
last_known_source_file_name,
|
||||
overall_status,
|
||||
content_error_count,
|
||||
transient_error_count,
|
||||
last_failure_instant,
|
||||
last_success_instant,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_target_path,
|
||||
last_target_file_name
|
||||
FROM document_record
|
||||
WHERE fingerprint = ?
|
||||
""";
|
||||
|
||||
try (Connection connection = getConnection();
|
||||
PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||
|
||||
stmt.setString(1, fingerprint.sha256Hex());
|
||||
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return Optional.of(mapToDocumentRecord(rs, fingerprint));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Dokument-Stammsatz konnte nicht geladen werden für Fingerprint '"
|
||||
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @return unveränderliche Liste der Versuche aufsteigend nach {@code attempt_number};
|
||||
* nie {@code null}; kann leer sein
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
*/
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
fingerprint, run_id, attempt_number, started_at, ended_at,
|
||||
status, failure_class, failure_message, retryable,
|
||||
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
|
||||
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
|
||||
final_target_file_name
|
||||
FROM processing_attempt
|
||||
WHERE fingerprint = ?
|
||||
ORDER BY attempt_number ASC
|
||||
""";
|
||||
|
||||
try (Connection connection = getConnection();
|
||||
Statement pragmaStmt = connection.createStatement();
|
||||
PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||
|
||||
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||
stmt.setString(1, fingerprint.sha256Hex());
|
||||
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
List<ProcessingAttempt> attempts = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
attempts.add(mapToProcessingAttempt(rs));
|
||||
}
|
||||
return List.copyOf(attempts);
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Verarbeitungsversuche konnten nicht geladen werden für Fingerprint '"
|
||||
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mapping-Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bildet eine ResultSet-Zeile auf eine {@link DocumentHistoryRow} ab.
|
||||
*
|
||||
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||
* @return die gemappte Zeile; nie {@code null}
|
||||
* @throws SQLException bei JDBC-Lesefehlern
|
||||
*/
|
||||
private DocumentHistoryRow mapToDocumentHistoryRow(ResultSet rs) throws SQLException {
|
||||
String fpHex = rs.getString("fingerprint");
|
||||
String statusStr = rs.getString("overall_status");
|
||||
String sourceFileName = rs.getString("last_known_source_file_name");
|
||||
String targetFileName = rs.getString("last_target_file_name"); // nullable
|
||||
String sourcePath = rs.getString("last_known_source_locator");
|
||||
String updatedAtStr = rs.getString("updated_at");
|
||||
long attemptCount = rs.getLong("attempt_count");
|
||||
|
||||
return new DocumentHistoryRow(
|
||||
new DocumentFingerprint(fpHex),
|
||||
ProcessingStatus.valueOf(statusStr),
|
||||
sourceFileName,
|
||||
targetFileName,
|
||||
sourcePath,
|
||||
stringToInstant(updatedAtStr),
|
||||
attemptCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet eine ResultSet-Zeile auf einen {@link DocumentRecord} ab.
|
||||
*
|
||||
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||
* @param fingerprint der Fingerprint, der bereits bekannt ist
|
||||
* @return der gemappte Stammsatz; nie {@code null}
|
||||
* @throws SQLException bei JDBC-Lesefehlern
|
||||
*/
|
||||
private DocumentRecord mapToDocumentRecord(ResultSet rs, DocumentFingerprint fingerprint) throws SQLException {
|
||||
return new DocumentRecord(
|
||||
fingerprint,
|
||||
new SourceDocumentLocator(rs.getString("last_known_source_locator")),
|
||||
rs.getString("last_known_source_file_name"),
|
||||
ProcessingStatus.valueOf(rs.getString("overall_status")),
|
||||
new FailureCounters(
|
||||
rs.getInt("content_error_count"),
|
||||
rs.getInt("transient_error_count")),
|
||||
stringToInstant(rs.getString("last_failure_instant")),
|
||||
stringToInstant(rs.getString("last_success_instant")),
|
||||
stringToInstant(rs.getString("created_at")),
|
||||
stringToInstant(rs.getString("updated_at")),
|
||||
rs.getString("last_target_path"),
|
||||
rs.getString("last_target_file_name"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet eine ResultSet-Zeile auf einen {@link ProcessingAttempt} ab.
|
||||
*
|
||||
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||
* @return der gemappte Versuch; nie {@code null}
|
||||
* @throws SQLException bei JDBC-Lesefehlern
|
||||
*/
|
||||
private ProcessingAttempt mapToProcessingAttempt(ResultSet rs) throws SQLException {
|
||||
String resolvedDateStr = rs.getString("resolved_date");
|
||||
LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;
|
||||
|
||||
String dateSourceStr = rs.getString("date_source");
|
||||
DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;
|
||||
|
||||
int processedPageCountRaw = rs.getInt("processed_page_count");
|
||||
Integer processedPageCount = rs.wasNull() ? null : processedPageCountRaw;
|
||||
|
||||
int sentCharacterCountRaw = rs.getInt("sent_character_count");
|
||||
Integer sentCharacterCount = rs.wasNull() ? null : sentCharacterCountRaw;
|
||||
|
||||
return new ProcessingAttempt(
|
||||
new DocumentFingerprint(rs.getString("fingerprint")),
|
||||
new RunId(rs.getString("run_id")),
|
||||
rs.getInt("attempt_number"),
|
||||
stringToInstant(rs.getString("started_at")),
|
||||
stringToInstant(rs.getString("ended_at")),
|
||||
ProcessingStatus.valueOf(rs.getString("status")),
|
||||
rs.getString("failure_class"),
|
||||
rs.getString("failure_message"),
|
||||
rs.getBoolean("retryable"),
|
||||
rs.getString("ai_provider"),
|
||||
rs.getString("model_name"),
|
||||
rs.getString("prompt_identifier"),
|
||||
processedPageCount,
|
||||
sentCharacterCount,
|
||||
rs.getString("ai_raw_response"),
|
||||
rs.getString("ai_reasoning"),
|
||||
resolvedDate,
|
||||
dateSource,
|
||||
rs.getString("validated_title"),
|
||||
rs.getString("final_target_file_name"));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SQL-LIKE Escaping
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Escaped Sonderzeichen {@code %} und {@code _} in einer LIKE-Eingabe mit {@code \}.
|
||||
* <p>
|
||||
* Der Escape-Charakter {@code \} muss in der SQL-Abfrage als
|
||||
* {@code ESCAPE '\'} angegeben werden.
|
||||
*
|
||||
* @param input die rohe Benutzereingabe; darf nicht {@code null} sein
|
||||
* @return der escaped String; nie {@code null}
|
||||
*/
|
||||
private static String escapeSqlLike(String input) {
|
||||
return input
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JDBC-Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Öffnet eine neue Datenbankverbindung zur konfigurierten SQLite-Datei.
|
||||
* <p>
|
||||
* Kann in Unterklassen überschrieben werden, um eine gemeinsam genutzte
|
||||
* Transaktions-Verbindung bereitzustellen.
|
||||
*
|
||||
* @return eine neue Datenbankverbindung
|
||||
* @throws SQLException wenn die Verbindung nicht hergestellt werden kann
|
||||
*/
|
||||
protected Connection getConnection() throws SQLException {
|
||||
return DriverManager.getConnection(jdbcUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst einen Instant aus einer String-Darstellung.
|
||||
* <p>
|
||||
* Unterstützt ISO-8601 (modern) und das Legacy-Format {@code yyyy-MM-dd HH:mm:ss} (UTC).
|
||||
*
|
||||
* @param stringValue die String-Darstellung; kann {@code null} sein
|
||||
* @return das geparste Instant, oder {@code null} wenn die Eingabe leer oder nicht parsbar ist
|
||||
*/
|
||||
private Instant stringToInstant(String stringValue) {
|
||||
if (stringValue == null || stringValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Instant.parse(stringValue);
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
LocalDateTime dateTime = LocalDateTime.parse(stringValue,
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
return dateTime.atZone(ZoneId.of("UTC")).toInstant();
|
||||
} catch (Exception fallback) {
|
||||
logger.warn("Instant konnte nicht geparst werden '{}': {}", stringValue, fallback.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -171,7 +171,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||
*/
|
||||
@Override
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// Delete attempts first (FK constraint: processing_attempt → document_record)
|
||||
// Zuerst Versuche löschen (FK-Constraint: processing_attempt → document_record)
|
||||
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
|
||||
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
|
||||
@Override
|
||||
@@ -181,7 +181,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||
};
|
||||
attemptRepo.deleteAllByFingerprint(fingerprint);
|
||||
|
||||
// Then delete the master record
|
||||
// Dann den Stammsatz löschen
|
||||
SqliteDocumentRecordRepositoryAdapter recordRepo =
|
||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
|
||||
@Override
|
||||
@@ -191,5 +191,45 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||
};
|
||||
recordRepo.deleteByFingerprint(fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
|
||||
* ohne die Versuchshistorie zu löschen.
|
||||
* <p>
|
||||
* Die Felder {@code overall_status}, {@code content_error_count},
|
||||
* {@code transient_error_count} und {@code last_failure_instant} werden innerhalb
|
||||
* der laufenden Transaktion per direktem SQL-UPDATE aktualisiert.
|
||||
* Alle anderen Felder sowie alle {@code processing_attempt}-Einträge bleiben unverändert.
|
||||
* <p>
|
||||
* Ist kein Stammsatz für den Fingerprint vorhanden, kehrt die Methode stillschweigend zurück.
|
||||
*
|
||||
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
|
||||
* darf nicht {@code null} sein
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
*/
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
|
||||
String sql = """
|
||||
UPDATE document_record SET
|
||||
overall_status = 'READY_FOR_AI',
|
||||
content_error_count = 0,
|
||||
transient_error_count = 0,
|
||||
last_failure_instant = NULL
|
||||
WHERE fingerprint = ?
|
||||
""";
|
||||
|
||||
try (java.sql.PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||
stmt.setString(1, fingerprint.sha256Hex());
|
||||
stmt.executeUpdate();
|
||||
logger.debug("Status-Reset (feldgenau) für Fingerprint: {}", fingerprint.sha256Hex());
|
||||
} catch (java.sql.SQLException e) {
|
||||
String message = "Status-Reset fehlgeschlagen für Fingerprint '"
|
||||
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user