fix(import): address PR review concerns
- remove duplicate List import in AdminControllerTest - derive skipped() from skippedFiles.size() — drop redundant int field - use machine codes for SkippedFile.reason (INVALID_PDF_SIGNATURE, FILE_READ_ERROR) - map reason codes to i18n strings in ImportStatusCard (de/en/es) - replace raw amber Tailwind classes with warning semantic token - fix <summary> accessibility: replace list-none with rotating chevron SVG - replace <p> with <span> inside <summary> (phrasing content rule) - extract setupOneValidOneFakeImport() helper — remove 3x copy-paste - add lenient mock to short-file test for defensive coverage - add IOException path test for isPdfMagicBytes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.poi.ss.usermodel.*;
|
import org.apache.poi.ss.usermodel.*;
|
||||||
@@ -56,11 +57,14 @@ public class MassImportService {
|
|||||||
|
|
||||||
public record SkippedFile(String filename, String reason) {}
|
public record SkippedFile(String filename, String reason) {}
|
||||||
|
|
||||||
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, int skipped, List<SkippedFile> skippedFiles, LocalDateTime startedAt) {}
|
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, List<SkippedFile> skippedFiles, LocalDateTime startedAt) {
|
||||||
|
@JsonProperty("skipped")
|
||||||
|
public int skipped() { return skippedFiles.size(); }
|
||||||
|
}
|
||||||
|
|
||||||
record ProcessResult(int processed, int skipped, List<SkippedFile> skippedFiles) {}
|
record ProcessResult(int processed, List<SkippedFile> skippedFiles) {}
|
||||||
|
|
||||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, 0, List.of(), null);
|
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
|
|
||||||
public ImportStatus getStatus() {
|
public ImportStatus getStatus() {
|
||||||
return currentStatus;
|
return currentStatus;
|
||||||
@@ -122,22 +126,22 @@ public class MassImportService {
|
|||||||
if (currentStatus.state() == State.RUNNING) {
|
if (currentStatus.state() == State.RUNNING) {
|
||||||
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
||||||
}
|
}
|
||||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, 0, List.of(), LocalDateTime.now());
|
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now());
|
||||||
try {
|
try {
|
||||||
File spreadsheet = findSpreadsheetFile();
|
File spreadsheet = findSpreadsheetFile();
|
||||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
||||||
ProcessResult result = processRows(readSpreadsheet(spreadsheet));
|
ProcessResult result = processRows(readSpreadsheet(spreadsheet));
|
||||||
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||||
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
|
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
|
||||||
result.processed(), result.skipped(), result.skippedFiles(), currentStatus.startedAt());
|
result.processed(), result.skippedFiles(), currentStatus.startedAt());
|
||||||
} catch (NoSpreadsheetException e) {
|
} catch (NoSpreadsheetException e) {
|
||||||
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
||||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
||||||
"Fehler: " + e.getMessage(), 0, 0, List.of(), currentStatus.startedAt());
|
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Massenimport fehlgeschlagen", e);
|
log.error("Massenimport fehlgeschlagen", e);
|
||||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
||||||
"Fehler: " + e.getMessage(), 0, 0, List.of(), currentStatus.startedAt());
|
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,12 +282,12 @@ public class MassImportService {
|
|||||||
try {
|
try {
|
||||||
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
||||||
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
|
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
|
||||||
skippedFiles.add(new SkippedFile(filename, "Keine gültige PDF-Signatur"));
|
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
|
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
|
||||||
skippedFiles.add(new SkippedFile(filename, "Fehler beim Lesen der Datei"));
|
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,7 +297,7 @@ public class MassImportService {
|
|||||||
processed++;
|
processed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new ProcessResult(processed, skippedFiles.size(), skippedFiles);
|
return new ProcessResult(processed, skippedFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isPdfMagicBytes(File file) throws IOException {
|
private boolean isPdfMagicBytes(File file) throws IOException {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import java.util.zip.ZipOutputStream;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ class MassImportServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, 0, List.of(), LocalDateTime.now());
|
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
|
||||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.runImportAsync())
|
assertThatThrownBy(() -> service.runImportAsync())
|
||||||
@@ -529,14 +530,7 @@ class MassImportServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
|
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
|
||||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
setupOneValidOneFakeImport(tempDir);
|
||||||
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
|
||||||
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
|
||||||
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
|
||||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
|
||||||
|
|
||||||
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
service.runImportAsync();
|
service.runImportAsync();
|
||||||
|
|
||||||
@@ -545,14 +539,7 @@ class MassImportServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
|
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
|
||||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
setupOneValidOneFakeImport(tempDir);
|
||||||
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
|
||||||
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
|
||||||
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
|
||||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
|
||||||
|
|
||||||
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
service.runImportAsync();
|
service.runImportAsync();
|
||||||
|
|
||||||
@@ -561,14 +548,7 @@ class MassImportServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
|
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
|
||||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
setupOneValidOneFakeImport(tempDir);
|
||||||
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
|
||||||
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
|
||||||
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
|
||||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
|
||||||
|
|
||||||
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
service.runImportAsync();
|
service.runImportAsync();
|
||||||
|
|
||||||
@@ -582,12 +562,31 @@ class MassImportServiceTest {
|
|||||||
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
|
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
|
||||||
buildMinimalImportXlsx(tempDir, "tiny.pdf");
|
buildMinimalImportXlsx(tempDir, "tiny.pdf");
|
||||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
service.runImportAsync();
|
service.runImportAsync();
|
||||||
|
|
||||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
|
||||||
|
Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
|
||||||
|
File unreadable = tempDir.resolve("unreadable.pdf").toFile();
|
||||||
|
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
assumeTrue(unreadable.setReadable(false), "Requires non-root file permissions");
|
||||||
|
try {
|
||||||
|
service.runImportAsync();
|
||||||
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
|
assertThat(service.getStatus().skippedFiles())
|
||||||
|
.extracting(MassImportService.SkippedFile::reason)
|
||||||
|
.containsExactly("FILE_READ_ERROR");
|
||||||
|
} finally {
|
||||||
|
unreadable.setReadable(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── readOds — XXE security regression ───────────────────────────────────
|
// ─── readOds — XXE security regression ───────────────────────────────────
|
||||||
|
|
||||||
// Security regression — do not remove.
|
// Security regression — do not remove.
|
||||||
@@ -685,6 +684,16 @@ class MassImportServiceTest {
|
|||||||
return destination.toFile();
|
return destination.toFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
|
||||||
|
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||||
|
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
||||||
|
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
||||||
|
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
}
|
||||||
|
|
||||||
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
|
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
|
||||||
Path xlsx = dir.resolve("import.xlsx");
|
Path xlsx = dir.resolve("import.xlsx");
|
||||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -48,7 +47,7 @@ class AdminControllerTest {
|
|||||||
@WithMockUser(authorities = "ADMIN")
|
@WithMockUser(authorities = "ADMIN")
|
||||||
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
||||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, 0, List.of(), null);
|
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
when(massImportService.getStatus()).thenReturn(status);
|
when(massImportService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
mockMvc.perform(get("/api/admin/import-status"))
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
@@ -62,7 +61,7 @@ class AdminControllerTest {
|
|||||||
@WithMockUser(authorities = "ADMIN")
|
@WithMockUser(authorities = "ADMIN")
|
||||||
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
||||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, 0, List.of(), null);
|
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
when(massImportService.getStatus()).thenReturn(status);
|
when(massImportService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
mockMvc.perform(get("/api/admin/import-status"))
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
|
|||||||
@@ -353,6 +353,8 @@
|
|||||||
"admin_system_import_status_done": "Import abgeschlossen",
|
"admin_system_import_status_done": "Import abgeschlossen",
|
||||||
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
||||||
"admin_system_import_skipped_label": "übersprungen",
|
"admin_system_import_skipped_label": "übersprungen",
|
||||||
|
"import_reason_invalid_pdf_signature": "Keine gültige PDF-Signatur",
|
||||||
|
"import_reason_file_read_error": "Fehler beim Lesen der Datei",
|
||||||
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
||||||
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
||||||
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
||||||
|
|||||||
@@ -353,6 +353,8 @@
|
|||||||
"admin_system_import_status_done": "Import complete",
|
"admin_system_import_status_done": "Import complete",
|
||||||
"admin_system_import_status_done_label": "Documents processed",
|
"admin_system_import_status_done_label": "Documents processed",
|
||||||
"admin_system_import_skipped_label": "skipped",
|
"admin_system_import_skipped_label": "skipped",
|
||||||
|
"import_reason_invalid_pdf_signature": "Invalid PDF signature",
|
||||||
|
"import_reason_file_read_error": "File read error",
|
||||||
"admin_system_import_status_failed": "Import failed",
|
"admin_system_import_status_failed": "Import failed",
|
||||||
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
||||||
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
||||||
|
|||||||
@@ -353,6 +353,8 @@
|
|||||||
"admin_system_import_status_done": "Importación completada",
|
"admin_system_import_status_done": "Importación completada",
|
||||||
"admin_system_import_status_done_label": "Documentos procesados",
|
"admin_system_import_status_done_label": "Documentos procesados",
|
||||||
"admin_system_import_skipped_label": "omitidos",
|
"admin_system_import_skipped_label": "omitidos",
|
||||||
|
"import_reason_invalid_pdf_signature": "Firma PDF no válida",
|
||||||
|
"import_reason_file_read_error": "Error al leer el archivo",
|
||||||
"admin_system_import_status_failed": "Importación fallida",
|
"admin_system_import_status_failed": "Importación fallida",
|
||||||
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
||||||
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const failureMessage = $derived(
|
|||||||
? m.admin_system_import_failed_no_spreadsheet()
|
? m.admin_system_import_failed_no_spreadsheet()
|
||||||
: m.admin_system_import_failed_internal()
|
: m.admin_system_import_failed_internal()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function reasonLabel(code: string): string {
|
||||||
|
if (code === 'INVALID_PDF_SIGNATURE') return m.import_reason_invalid_pdf_signature();
|
||||||
|
if (code === 'FILE_READ_ERROR') return m.import_reason_file_read_error();
|
||||||
|
return code;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
@@ -49,16 +55,31 @@ const failureMessage = $derived(
|
|||||||
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
|
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if importStatus.skipped > 0}
|
{#if importStatus.skipped > 0}
|
||||||
<details class="mb-4 rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-700">
|
<details class="mb-4 rounded-sm border border-warning/40 bg-warning/10 p-4 text-warning">
|
||||||
<summary class="cursor-pointer list-none">
|
<summary class="flex cursor-pointer list-none items-center gap-2">
|
||||||
<p data-testid="skipped-count" class="text-base font-bold">{importStatus.skipped}</p>
|
<svg
|
||||||
<p class="font-sans text-xs font-bold tracking-widest text-amber-800 uppercase">
|
class="details-chevron h-4 w-4 shrink-0 transition-transform"
|
||||||
{m.admin_system_import_skipped_label()}
|
viewBox="0 0 16 16"
|
||||||
</p>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M6 4l4 4-4 4" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span data-testid="skipped-count" class="block text-base font-bold"
|
||||||
|
>{importStatus.skipped}</span
|
||||||
|
>
|
||||||
|
<span class="block font-sans text-xs font-bold tracking-widest uppercase">
|
||||||
|
{m.admin_system_import_skipped_label()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
<ul class="mt-3 space-y-1">
|
<ul class="mt-3 space-y-1">
|
||||||
{#each importStatus.skippedFiles as { filename, reason } (filename)}
|
{#each importStatus.skippedFiles as { filename, reason } (filename)}
|
||||||
<li class="font-mono text-xs text-ink-2">{filename} — {reason}</li>
|
<li class="font-mono text-xs text-ink-2">{filename} — {reasonLabel(reason)}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
@@ -94,3 +115,9 @@ const failureMessage = $derived(
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
details[open] .details-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -140,9 +140,9 @@ describe('ImportStatusCard', () => {
|
|||||||
processed: 10,
|
processed: 10,
|
||||||
skipped: 3,
|
skipped: 3,
|
||||||
skippedFiles: [
|
skippedFiles: [
|
||||||
{ filename: 'fake.pdf', reason: 'Keine gültige PDF-Signatur' },
|
{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||||
{ filename: 'other.pdf', reason: 'Keine gültige PDF-Signatur' },
|
{ filename: 'other.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||||
{ filename: 'tiny.pdf', reason: 'Keine gültige PDF-Signatur' }
|
{ filename: 'tiny.pdf', reason: 'INVALID_PDF_SIGNATURE' }
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
ontrigger: () => {}
|
ontrigger: () => {}
|
||||||
@@ -160,7 +160,7 @@ describe('ImportStatusCard', () => {
|
|||||||
statusCode: 'IMPORT_DONE',
|
statusCode: 'IMPORT_DONE',
|
||||||
processed: 5,
|
processed: 5,
|
||||||
skipped: 1,
|
skipped: 1,
|
||||||
skippedFiles: [{ filename: 'fake.pdf', reason: 'Keine gültige PDF-Signatur' }]
|
skippedFiles: [{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
|
||||||
}),
|
}),
|
||||||
ontrigger: () => {}
|
ontrigger: () => {}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user