#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:
2026-04-30 13:57:07 +02:00
parent 5d5dee0bbf
commit 46fc1d4fa4
31 changed files with 3095 additions and 17 deletions
@@ -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;
}
}
}
}
@@ -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);
}
}
}
}