Compare commits
47 Commits
fix/issue-
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50621f9a15 | ||
|
|
1fca1f80a2 | ||
|
|
46dae8a826 | ||
|
|
e5fe2fc5c6 | ||
|
|
0ab85d888b | ||
|
|
48c82aa07b | ||
|
|
1299f191e2 | ||
|
|
9aed929b67 | ||
|
|
cb9962f0c2 | ||
|
|
262c792654 | ||
|
|
60f1db1f99 | ||
|
|
8cf4f7c2e4 | ||
|
|
6b10daeeac | ||
|
|
74b473e3d7 | ||
|
|
f1b3e8c2d8 | ||
|
|
c78a1d69dc | ||
|
|
5131c8da31 | ||
|
|
eb106c9ca7 | ||
|
|
e742c36ef6 | ||
|
|
9ac01f7cc2 | ||
|
|
a2a7d547ee | ||
|
|
3c99030546 | ||
|
|
f75a960179 | ||
|
|
811baf78da | ||
|
|
43122c20cb | ||
|
|
f90d4b282e | ||
|
|
1eb833f333 | ||
|
|
b2264de949 | ||
|
|
dd6331c098 | ||
|
|
9d687ba9f9 | ||
|
|
1ea95f8fe0 | ||
|
|
65846911f3 | ||
|
|
75dd8cb08d | ||
|
|
db6a3225db | ||
|
|
8b05451f42 | ||
|
|
aa9c47ecc8 | ||
|
|
0e6efc9170 | ||
|
|
64dbce2a00 | ||
|
|
a1f9253712 | ||
|
|
3a6a70a1f7 | ||
|
|
edd96b05fe | ||
|
|
6d5fb9d8c8 | ||
|
|
1f1b7aeab5 | ||
|
|
22bba5cfcd | ||
|
|
4248d8af72 | ||
|
|
f86105a1be | ||
|
|
ae445a78ae |
@@ -18,6 +18,7 @@ import jakarta.validation.constraints.Min;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
@@ -193,6 +194,7 @@ public class DocumentController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public QuickUploadResult quickUpload(
|
public QuickUploadResult quickUpload(
|
||||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
||||||
|
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
List<Document> created = new ArrayList<>();
|
List<Document> created = new ArrayList<>();
|
||||||
List<Document> updated = new ArrayList<>();
|
List<Document> updated = new ArrayList<>();
|
||||||
@@ -202,14 +204,21 @@ public class DocumentController {
|
|||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
documentService.validateBatch(files.size(), metadata);
|
||||||
|
|
||||||
UUID actorId = requireUserId(authentication);
|
UUID actorId = requireUserId(authentication);
|
||||||
for (MultipartFile file : files) {
|
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
|
||||||
|
|
||||||
|
for (int i = 0; i < files.size(); i++) {
|
||||||
|
MultipartFile file = files.get(i);
|
||||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
DocumentService.StoreResult result = documentService.storeDocument(file, actorId);
|
DocumentService.StoreResult result = metadata != null
|
||||||
|
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
|
||||||
|
: documentService.storeDocument(file, actorId);
|
||||||
if (result.isNew()) {
|
if (result.isNew()) {
|
||||||
created.add(result.document());
|
created.add(result.document());
|
||||||
} else {
|
} else {
|
||||||
@@ -221,6 +230,10 @@ public class DocumentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
|
||||||
|
actorId, files.size(), totalBytes, metadata != null,
|
||||||
|
created.size(), updated.size(), errors.size());
|
||||||
|
|
||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class DocumentBatchMetadataDTO {
|
||||||
|
private List<String> titles;
|
||||||
|
private UUID senderId;
|
||||||
|
private List<UUID> receiverIds;
|
||||||
|
private LocalDate documentDate;
|
||||||
|
private String location;
|
||||||
|
private List<String> tagNames;
|
||||||
|
private Boolean metadataComplete;
|
||||||
|
}
|
||||||
@@ -109,6 +109,8 @@ public enum ErrorCode {
|
|||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
|
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
||||||
|
BATCH_TOO_LARGE,
|
||||||
/** An unexpected server-side error occurred. 500 */
|
/** An unexpected server-side error occurred. 500 */
|
||||||
INTERNAL_ERROR,
|
INTERNAL_ERROR,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
@@ -132,6 +133,52 @@ public class DocumentService {
|
|||||||
return new StoreResult(saved, isNew);
|
return new StoreResult(saved, isNew);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
||||||
|
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
|
||||||
|
if (fileCount > 50) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
||||||
|
}
|
||||||
|
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public StoreResult storeDocumentWithBatchMetadata(
|
||||||
|
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
|
||||||
|
StoreResult base = storeDocument(file, actorId);
|
||||||
|
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
|
||||||
|
return new StoreResult(documentRepository.save(doc), base.isNew());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
|
||||||
|
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
|
||||||
|
doc.setTitle(metadata.getTitles().get(fileIndex));
|
||||||
|
}
|
||||||
|
if (metadata.getSenderId() != null) {
|
||||||
|
doc.setSender(personService.getById(metadata.getSenderId()));
|
||||||
|
}
|
||||||
|
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
|
||||||
|
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
|
||||||
|
}
|
||||||
|
if (metadata.getDocumentDate() != null) {
|
||||||
|
doc.setDocumentDate(metadata.getDocumentDate());
|
||||||
|
}
|
||||||
|
if (metadata.getLocation() != null) {
|
||||||
|
doc.setLocation(metadata.getLocation());
|
||||||
|
}
|
||||||
|
if (metadata.getMetadataComplete() != null) {
|
||||||
|
doc.setMetadataComplete(metadata.getMetadataComplete());
|
||||||
|
}
|
||||||
|
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
|
||||||
|
UUID docId = doc.getId();
|
||||||
|
updateDocumentTags(docId, metadata.getTagNames());
|
||||||
|
doc = documentRepository.findById(docId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
||||||
String filename = (file != null && !file.isEmpty())
|
String filename = (file != null && !file.isEmpty())
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ spring:
|
|||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 50MB
|
max-file-size: 50MB
|
||||||
max-request-size: 50MB
|
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
|
||||||
|
file-size-threshold: 2KB
|
||||||
|
|
||||||
mail:
|
mail:
|
||||||
host: ${MAIL_HOST:}
|
host: ${MAIL_HOST:}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
@@ -766,4 +768,165 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
.andExpect(jsonPath("$.editorName").value("Otto"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||||
|
|
||||||
|
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
|
||||||
|
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
|
||||||
|
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
|
||||||
|
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc1, true));
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc2, true));
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc3, true));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile f1 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f2 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f3 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created.length()").value(3))
|
||||||
|
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||||
|
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
|
||||||
|
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
|
||||||
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||||
|
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
|
||||||
|
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(existing, false));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
|
||||||
|
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||||
|
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||||
|
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
|
||||||
|
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(docA, true));
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(docB, true));
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(docC, true));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile f1 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f2 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f3 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||||
|
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||||
|
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
org.mockito.Mockito.doThrow(
|
||||||
|
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
|
||||||
|
.when(documentService).validateBatch(eq(2), any());
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile f1 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f2 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
|
||||||
|
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc, true));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||||
|
.containsExactly("Briefwechsel", "Krieg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
org.mockito.Mockito.doThrow(
|
||||||
|
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
|
||||||
|
.when(documentService).validateBatch(eq(51), any());
|
||||||
|
|
||||||
|
var builder = multipart("/api/documents/quick-upload");
|
||||||
|
for (int i = 0; i < 51; i++) {
|
||||||
|
builder.file(new org.springframework.mock.web.MockMultipartFile(
|
||||||
|
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.perform(builder)
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1813,4 +1813,108 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── storeDocumentWithBatchMetadata ──────────────────────────────────────
|
||||||
|
|
||||||
|
private MockMultipartFile pdfFile(String name) {
|
||||||
|
return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubStoreDocument(String filename) throws Exception {
|
||||||
|
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
|
||||||
|
stubStoreDocument("scan01.pdf");
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));
|
||||||
|
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);
|
||||||
|
|
||||||
|
assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
stubStoreDocument("scan02.pdf");
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Person sender = Person.builder().id(senderId).firstName("Anna").build();
|
||||||
|
when(personService.getById(senderId)).thenReturn(sender);
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
meta.setSenderId(senderId);
|
||||||
|
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);
|
||||||
|
|
||||||
|
assertThat(result.document().getSender()).isEqualTo(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
Document d = inv.getArgument(0);
|
||||||
|
if (d.getId() == null) d.setId(docId);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
when(documentRepository.findById(docId)).thenAnswer(inv -> {
|
||||||
|
Document d = new Document();
|
||||||
|
d.setId(docId);
|
||||||
|
return Optional.of(d);
|
||||||
|
});
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||||
|
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
meta.setTagNames(List.of("Familie"));
|
||||||
|
|
||||||
|
documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);
|
||||||
|
|
||||||
|
verify(tagService).findOrCreate("Familie");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
|
||||||
|
stubStoreDocument("scan04.pdf");
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
meta.setTitles(List.of("Only One Title"));
|
||||||
|
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);
|
||||||
|
|
||||||
|
assertThat(result.document().getTitle()).isEqualTo("scan04");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── validateBatch ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
|
||||||
|
assertThatThrownBy(() -> documentService.validateBatch(51, null))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("50");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
|
||||||
|
documentService.validateBatch(50, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
|
||||||
|
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
metadata.setTitles(java.util.List.of("A", "B", "C"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("titles");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -850,5 +850,29 @@
|
|||||||
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
||||||
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
||||||
"richtlinien_closing_title": "Fehlt eine Regel?",
|
"richtlinien_closing_title": "Fehlt eine Regel?",
|
||||||
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen."
|
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen.",
|
||||||
|
"error_batch_too_large": "Zu viele Dateien auf einmal — bitte in Blöcken hochladen.",
|
||||||
|
"bulk_drop_hint": "Eine oder mehrere Dateien ablegen",
|
||||||
|
"bulk_drop_sub": "PDF · bis zu 50 MB pro Datei",
|
||||||
|
"bulk_count_pill": "{count} werden erstellt",
|
||||||
|
"bulk_save_cta_one": "Speichern →",
|
||||||
|
"bulk_save_cta": "{count} speichern →",
|
||||||
|
"bulk_discard_all": "Alle verwerfen",
|
||||||
|
"bulk_discard_confirm": "Alle Dateien und eingegebenen Daten verwerfen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"bulk_add_more": "Weitere hinzufügen",
|
||||||
|
"bulk_scope_per_file_label": "Nur diese Datei",
|
||||||
|
"bulk_scope_shared_label": "Gilt für alle {count}",
|
||||||
|
"bulk_title_suggested_hint": "Vorschlag aus Dateiname — zum Bearbeiten anklicken",
|
||||||
|
"bulk_switcher_prev": "Vorherige Datei",
|
||||||
|
"bulk_switcher_next": "Nächste Datei",
|
||||||
|
"bulk_file_error_chip_label": "Fehler beim Hochladen",
|
||||||
|
"bulk_upload_progress": "{done} von {total} hochgeladen",
|
||||||
|
"bulk_partial_success": "{created} erstellt, {failed} fehlgeschlagen",
|
||||||
|
"bulk_all_failed": "Alle Uploads fehlgeschlagen",
|
||||||
|
"bulk_drop_desc": "Für jede Datei wird ein eigenes Dokument erstellt. Der Titel wird aus dem Dateinamen vorausgefüllt — alle anderen Felder gelten für alle gemeinsam.",
|
||||||
|
"bulk_select_files": "Dateien auswählen",
|
||||||
|
"bulk_drop_zone_label": "Dateien ablegen",
|
||||||
|
"bulk_remove_file": "Entfernen",
|
||||||
|
"bulk_title_single": "Neues Dokument",
|
||||||
|
"bulk_title_multi": "Neue Dokumente"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -850,5 +850,29 @@
|
|||||||
"richtlinien_klaer_umbrueche": "Original line breaks",
|
"richtlinien_klaer_umbrueche": "Original line breaks",
|
||||||
"richtlinien_klaer_caps": "Old capitalisation",
|
"richtlinien_klaer_caps": "Old capitalisation",
|
||||||
"richtlinien_closing_title": "Missing a rule?",
|
"richtlinien_closing_title": "Missing a rule?",
|
||||||
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering."
|
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering.",
|
||||||
|
"error_batch_too_large": "Too many files at once — please upload in smaller batches.",
|
||||||
|
"bulk_drop_hint": "Drop one or more files here",
|
||||||
|
"bulk_drop_sub": "PDF · up to 50 MB per file",
|
||||||
|
"bulk_count_pill": "{count} will be created",
|
||||||
|
"bulk_save_cta_one": "Save →",
|
||||||
|
"bulk_save_cta": "Save {count} →",
|
||||||
|
"bulk_discard_all": "Discard all",
|
||||||
|
"bulk_discard_confirm": "Discard all files and entered data? This action cannot be undone.",
|
||||||
|
"bulk_add_more": "Add more",
|
||||||
|
"bulk_scope_per_file_label": "This file only",
|
||||||
|
"bulk_scope_shared_label": "Applies to all {count}",
|
||||||
|
"bulk_title_suggested_hint": "Suggested from filename — click to edit",
|
||||||
|
"bulk_switcher_prev": "Previous file",
|
||||||
|
"bulk_switcher_next": "Next file",
|
||||||
|
"bulk_file_error_chip_label": "Upload failed",
|
||||||
|
"bulk_upload_progress": "{done} of {total} uploaded",
|
||||||
|
"bulk_partial_success": "{created} created, {failed} failed",
|
||||||
|
"bulk_all_failed": "All uploads failed",
|
||||||
|
"bulk_drop_desc": "A separate document is created for each file. The title is pre-filled from the filename — all other fields apply to all documents.",
|
||||||
|
"bulk_select_files": "Select files",
|
||||||
|
"bulk_drop_zone_label": "Drop files here",
|
||||||
|
"bulk_remove_file": "Remove",
|
||||||
|
"bulk_title_single": "New Document",
|
||||||
|
"bulk_title_multi": "New Documents"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -850,5 +850,29 @@
|
|||||||
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
||||||
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
||||||
"richtlinien_closing_title": "¿Falta una regla?",
|
"richtlinien_closing_title": "¿Falta una regla?",
|
||||||
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar."
|
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar.",
|
||||||
|
"error_batch_too_large": "Demasiados archivos a la vez — sube en lotes más pequeños.",
|
||||||
|
"bulk_drop_hint": "Suelta uno o varios archivos aquí",
|
||||||
|
"bulk_drop_sub": "PDF · hasta 50 MB por archivo",
|
||||||
|
"bulk_count_pill": "Se crearán {count}",
|
||||||
|
"bulk_save_cta_one": "Guardar →",
|
||||||
|
"bulk_save_cta": "Guardar {count} →",
|
||||||
|
"bulk_discard_all": "Descartar todo",
|
||||||
|
"bulk_discard_confirm": "¿Descartar todos los archivos y datos introducidos? Esta acción no se puede deshacer.",
|
||||||
|
"bulk_add_more": "Añadir más",
|
||||||
|
"bulk_scope_per_file_label": "Solo este archivo",
|
||||||
|
"bulk_scope_shared_label": "Para todos los {count}",
|
||||||
|
"bulk_title_suggested_hint": "Sugerencia del nombre de archivo — haz clic para editar",
|
||||||
|
"bulk_switcher_prev": "Archivo anterior",
|
||||||
|
"bulk_switcher_next": "Archivo siguiente",
|
||||||
|
"bulk_file_error_chip_label": "Error al subir",
|
||||||
|
"bulk_upload_progress": "{done} de {total} subidos",
|
||||||
|
"bulk_partial_success": "{created} creados, {failed} fallidos",
|
||||||
|
"bulk_all_failed": "Todos los uploads fallaron",
|
||||||
|
"bulk_drop_desc": "Se crea un documento separado por archivo. El título se rellena desde el nombre del archivo — el resto de campos se aplican a todos.",
|
||||||
|
"bulk_select_files": "Seleccionar archivos",
|
||||||
|
"bulk_drop_zone_label": "Soltar archivos aquí",
|
||||||
|
"bulk_remove_file": "Eliminar",
|
||||||
|
"bulk_title_single": "Nuevo Documento",
|
||||||
|
"bulk_title_multi": "Nuevos Documentos"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
|
|||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||||
>
|
>
|
||||||
{#each selectedPersons as person (person.id)}
|
{#each selectedPersons as person (person.id)}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ function selectPerson(person: Person) {
|
|||||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
: compact
|
: compact
|
||||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onDestroy, untrack } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
|
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
|
import BulkDropZone from './BulkDropZone.svelte';
|
||||||
|
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||||
|
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||||
|
import ScopeCard from './ScopeCard.svelte';
|
||||||
|
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||||
|
import WhoWhenSection from './WhoWhenSection.svelte';
|
||||||
|
import DescriptionSection from './DescriptionSection.svelte';
|
||||||
|
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||||
|
import { bulkTitleFromFilename } from '$lib/utils/filename';
|
||||||
|
import type { Tag } from '$lib/components/TagInput.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
|
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
||||||
|
let _confirmService: ConfirmService | null;
|
||||||
|
try {
|
||||||
|
_confirmService = getConfirmService();
|
||||||
|
} catch {
|
||||||
|
_confirmService = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
initialSenderId = '',
|
||||||
|
initialSenderName = '',
|
||||||
|
initialReceivers = []
|
||||||
|
}: {
|
||||||
|
initialSenderId?: string;
|
||||||
|
initialSenderName?: string;
|
||||||
|
initialReceivers?: Person[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// --- File state ---
|
||||||
|
let files = new SvelteMap<string, FileEntry>();
|
||||||
|
let activeId = $state<string | null>(null);
|
||||||
|
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
// --- Shared metadata ---
|
||||||
|
let senderId = $state(untrack(() => initialSenderId));
|
||||||
|
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
||||||
|
let dateIso = $state('');
|
||||||
|
let tags = $state<Tag[]>([]);
|
||||||
|
|
||||||
|
// --- Derived ---
|
||||||
|
const isMulti = $derived(files.size >= 2);
|
||||||
|
const activeFile = $derived(activeId ? files.get(activeId) : null);
|
||||||
|
|
||||||
|
// --- File management ---
|
||||||
|
function addFiles(newFiles: File[]) {
|
||||||
|
for (const file of newFiles) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const title = bulkTitleFromFilename(file.name);
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
files.set(id, { id, file, title, status: 'idle', previewUrl });
|
||||||
|
if (!activeId) activeId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(id: string) {
|
||||||
|
const entry = files.get(id);
|
||||||
|
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||||
|
files.delete(id);
|
||||||
|
if (activeId === id) {
|
||||||
|
activeId = files.keys().next().value ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitle(id: string, title: string) {
|
||||||
|
const entry = files.get(id);
|
||||||
|
if (entry) files.set(id, { ...entry, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardAll() {
|
||||||
|
for (const entry of files.values()) {
|
||||||
|
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||||
|
}
|
||||||
|
files.clear();
|
||||||
|
activeId = null;
|
||||||
|
chunkProgress = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDiscard() {
|
||||||
|
if (_confirmService) {
|
||||||
|
const ok = await _confirmService.confirm({
|
||||||
|
title: m.bulk_discard_all(),
|
||||||
|
body: m.bulk_discard_confirm(),
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
discardAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const entry of files.values()) {
|
||||||
|
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Save ---
|
||||||
|
async function save() {
|
||||||
|
if (saving) return;
|
||||||
|
saving = true;
|
||||||
|
const entries = Array.from(files.values());
|
||||||
|
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
|
||||||
|
const chunkSize = 10;
|
||||||
|
const chunks: FileEntry[][] = [];
|
||||||
|
for (let i = 0; i < entries.length; i += chunkSize) {
|
||||||
|
chunks.push(entries.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
chunkProgress = { done: 0, total: chunks.length };
|
||||||
|
|
||||||
|
let hadErrors = false;
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const chunk = chunks[i];
|
||||||
|
const formData = new FormData();
|
||||||
|
chunk.forEach((entry) => formData.append('files', entry.file));
|
||||||
|
const metadata = {
|
||||||
|
titles: chunk.map((e) => e.title),
|
||||||
|
senderId: senderId || null,
|
||||||
|
receiverIds: selectedReceivers.map((r) => r.id),
|
||||||
|
documentDate: dateIso || null,
|
||||||
|
tagNames: tags.map((t) => t.name)
|
||||||
|
};
|
||||||
|
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||||
|
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
|
||||||
|
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||||
|
// by the browser for same-origin requests.
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||||
|
const body = await res.json().catch(() => ({ errors: [] }));
|
||||||
|
const errorFilenames = new Set<string>(
|
||||||
|
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||||
|
);
|
||||||
|
if (!res.ok || errorFilenames.size > 0) {
|
||||||
|
hadErrors = true;
|
||||||
|
for (const entry of chunk) {
|
||||||
|
// When backend names specific files, mark only those; otherwise mark all.
|
||||||
|
const isError = errorFilenames.size > 0 ? errorFilenames.has(entry.file.name) : true;
|
||||||
|
if (isError) {
|
||||||
|
const e = files.get(entry.id);
|
||||||
|
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
hadErrors = true;
|
||||||
|
for (const entry of chunk) {
|
||||||
|
const e = files.get(entry.id);
|
||||||
|
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunkProgress = { done: i + 1, total: chunks.length };
|
||||||
|
}
|
||||||
|
saving = false;
|
||||||
|
if (!hadErrors) goto('/documents');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
|
||||||
|
<!-- Topbar -->
|
||||||
|
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="/documents"
|
||||||
|
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{m.btn_back_to_overview()}
|
||||||
|
</a>
|
||||||
|
<span class="text-ink-3" aria-hidden="true">·</span>
|
||||||
|
<span class="font-serif text-sm font-bold text-ink">
|
||||||
|
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
|
||||||
|
</span>
|
||||||
|
{#if isMulti}
|
||||||
|
<span class="ml-auto flex items-center gap-3">
|
||||||
|
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
|
||||||
|
{m.bulk_count_pill({ count: files.size })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="discard-all-btn"
|
||||||
|
onclick={handleDiscard}
|
||||||
|
class="text-xs font-medium text-red-600/70 hover:text-red-700"
|
||||||
|
>
|
||||||
|
{m.bulk_discard_all()}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Split panel -->
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
<!-- Left: PDF preview / drop zone (55%) -->
|
||||||
|
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
|
||||||
|
{#if files.size === 0}
|
||||||
|
<!-- N=0: centred drop-zone box fills the panel -->
|
||||||
|
<BulkDropZone onFilesAdded={addFiles} />
|
||||||
|
{:else}
|
||||||
|
<!-- N≥1: real PDF preview via local blob URL -->
|
||||||
|
<div class="relative flex-1 overflow-hidden">
|
||||||
|
{#if activeFile}
|
||||||
|
<PdfViewer url={activeFile.previewUrl} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isMulti}
|
||||||
|
<!-- File switcher strip pinned to bottom of left panel -->
|
||||||
|
<FileSwitcherStrip
|
||||||
|
files={Array.from(files.values())}
|
||||||
|
activeId={activeId ?? ''}
|
||||||
|
onSelect={(id) => (activeId = id)}
|
||||||
|
onRemove={removeFile}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: metadata form (45%) -->
|
||||||
|
<div class="flex flex-[45] flex-col overflow-hidden">
|
||||||
|
<!-- Scrollable form area — greyed out and non-interactive when no files selected -->
|
||||||
|
<div
|
||||||
|
class="flex-1 space-y-4 overflow-y-auto p-4 transition-opacity"
|
||||||
|
class:opacity-60={files.size === 0}
|
||||||
|
class:pointer-events-none={files.size === 0}
|
||||||
|
>
|
||||||
|
{#if isMulti}
|
||||||
|
<!-- N≥2: per-file card (title) + shared card (metadata) -->
|
||||||
|
<ScopeCard variant="per-file">
|
||||||
|
{#if activeFile}
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.form_label_title()} <span class="text-danger">*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={activeFile.title}
|
||||||
|
oninput={(e) =>
|
||||||
|
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</ScopeCard>
|
||||||
|
|
||||||
|
<ScopeCard variant="shared" count={files.size}>
|
||||||
|
<WhoWhenSection
|
||||||
|
bind:senderId={senderId}
|
||||||
|
bind:selectedReceivers={selectedReceivers}
|
||||||
|
bind:dateIso={dateIso}
|
||||||
|
initialSenderName={initialSenderName}
|
||||||
|
/>
|
||||||
|
<DescriptionSection bind:tags={tags} hideTitle />
|
||||||
|
</ScopeCard>
|
||||||
|
{:else}
|
||||||
|
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.form_label_title()} <span class="text-danger">*</span>
|
||||||
|
</span>
|
||||||
|
{#if activeFile}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={activeFile.title}
|
||||||
|
oninput={(e) =>
|
||||||
|
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
placeholder="—"
|
||||||
|
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WhoWhenSection
|
||||||
|
bind:senderId={senderId}
|
||||||
|
bind:selectedReceivers={selectedReceivers}
|
||||||
|
bind:dateIso={dateIso}
|
||||||
|
initialSenderName={initialSenderName}
|
||||||
|
/>
|
||||||
|
<DescriptionSection bind:tags={tags} hideTitle />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action bar: always visible at bottom of right panel -->
|
||||||
|
<UploadSaveBar
|
||||||
|
fileCount={files.size}
|
||||||
|
chunkProgress={chunkProgress}
|
||||||
|
onSave={save}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||||
|
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeFile(name: string): File {
|
||||||
|
return new File(['content'], name, { type: 'application/pdf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFilesViaInput(container: HTMLElement, files: File[]): Promise<void> {
|
||||||
|
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
if (!input) throw new Error('No file input found — is BulkDropZone visible?');
|
||||||
|
await userEvent.upload(input, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BulkDocumentEditLayout', () => {
|
||||||
|
it('N=0: shows BulkDropZone', async () => {
|
||||||
|
render(BulkDocumentEditLayout, {});
|
||||||
|
await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('N=1: file-switcher-strip and per-file scope card are absent', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||||
|
expect(container.querySelector('[data-variant="per-file"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('N=5: file-switcher-strip and per-file scope card are both present', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [
|
||||||
|
makeFile('a.pdf'),
|
||||||
|
makeFile('b.pdf'),
|
||||||
|
makeFile('c.pdf'),
|
||||||
|
makeFile('d.pdf'),
|
||||||
|
makeFile('e.pdf')
|
||||||
|
]);
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing middle file preserves order of remaining files', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [
|
||||||
|
makeFile('file0.pdf'),
|
||||||
|
makeFile('file1.pdf'),
|
||||||
|
makeFile('file2.pdf')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Remove the chip for file1 via its remove button (identified by data-remove-id)
|
||||||
|
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'[data-testid="file-switcher-strip"] button[data-remove-id]'
|
||||||
|
);
|
||||||
|
expect(removeButtons.length).toBe(3);
|
||||||
|
removeButtons[1].click(); // remove file1
|
||||||
|
|
||||||
|
// Wait for Svelte to flush the DOM update
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const chips = container.querySelectorAll(
|
||||||
|
'[data-testid="file-switcher-strip"] [data-chip-id]'
|
||||||
|
);
|
||||||
|
expect(chips.length).toBe(2);
|
||||||
|
expect(chips[0].textContent?.trim()).toContain('file0');
|
||||||
|
expect(chips[1].textContent?.trim()).toContain('file2');
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save calls fetch twice for 12 files (2 chunks of 10)', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ created: [], updated: [], errors: [] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`));
|
||||||
|
await addFilesViaInput(container, files);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(saveBtn).not.toBeNull();
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
// Wait for async save to complete
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save marks file as error when server returns non-ok response', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() includes tagNames in metadata payload', async () => {
|
||||||
|
let capturedFormData: FormData | undefined;
|
||||||
|
const mockFetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
||||||
|
capturedFormData = init?.body as FormData;
|
||||||
|
return { ok: true, json: async () => ({ created: [], updated: [], errors: [] }) };
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
|
||||||
|
expect(capturedFormData).toBeDefined();
|
||||||
|
const metadataBlob = capturedFormData!.get('metadata') as Blob;
|
||||||
|
const metadataJson = JSON.parse(await metadataBlob.text());
|
||||||
|
expect(metadataJson).toHaveProperty('tagNames');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() navigates to /documents when all chunks succeed', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ created: [], updated: [], errors: [] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() does not navigate when chunk returns non-ok response', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save marks only the file whose filename matches the backend error, not adjacent files', async () => {
|
||||||
|
// backend returns error keyed to b.pdf — only b.pdf chip should get data-status="error"
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||||
|
expect(errorChips.length).toBe(1);
|
||||||
|
expect(errorChips[0].textContent).toContain('b');
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => {
|
||||||
|
// Backend can return 200 OK while reporting individual file failures
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
created: [{ id: '1' }],
|
||||||
|
updated: [],
|
||||||
|
errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||||
|
expect(errorChips.length).toBe(1);
|
||||||
|
expect(errorChips[0].textContent).toContain('b');
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
// Navigation should be suppressed because hadErrors is true
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() marks all chunk files as errored when fetch throws a network error', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||||
|
expect(errorChips.length).toBe(2);
|
||||||
|
},
|
||||||
|
{ timeout: 3000 }
|
||||||
|
);
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() does not call fetch a second time when already saving', async () => {
|
||||||
|
let resolveFirst: (() => void) | undefined;
|
||||||
|
const mockFetch = vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
resolveFirst = () =>
|
||||||
|
resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ created: [], updated: [], errors: [] })
|
||||||
|
} as Response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click(); // first click — fetch is in-flight
|
||||||
|
saveBtn.click(); // second click — should be a no-op
|
||||||
|
|
||||||
|
resolveFirst?.();
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard-all does not clear files when the user cancels the confirm dialog', async () => {
|
||||||
|
const service = createConfirmService();
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
context: new Map([[CONFIRM_KEY, service]])
|
||||||
|
});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||||
|
|
||||||
|
const discardBtn = container.querySelector(
|
||||||
|
'button[data-testid="discard-all-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
discardBtn.click();
|
||||||
|
|
||||||
|
// The confirm dialog should open (service.options not null)
|
||||||
|
await vi.waitFor(() => expect(service.options).not.toBeNull(), { timeout: 1000 });
|
||||||
|
|
||||||
|
// Cancel — files should remain
|
||||||
|
service.settle(false);
|
||||||
|
await vi.waitFor(
|
||||||
|
() => expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(),
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||||
|
|
||||||
|
// Confirm N=2 state — switcher is visible
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||||
|
|
||||||
|
// Click the topbar discard-all button (only visible in isMulti state)
|
||||||
|
const discardBtn = container.querySelector(
|
||||||
|
'button[data-testid="discard-all-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(discardBtn).not.toBeNull();
|
||||||
|
discardBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onFilesAdded
|
||||||
|
}: {
|
||||||
|
onFilesAdded: (files: File[]) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label={m.bulk_drop_zone_label()}
|
||||||
|
aria-describedby="bulk-drop-desc"
|
||||||
|
data-testid="bulk-drop-zone"
|
||||||
|
class="flex flex-1 flex-col items-center justify-center p-6"
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (isDragging = false)}
|
||||||
|
ondrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||||
|
onFilesAdded(Array.from(e.dataTransfer.files));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'flex w-full max-w-xl flex-col items-center gap-5 rounded-md border-2 border-dashed px-12 py-16 text-center transition-colors',
|
||||||
|
isDragging ? 'border-accent bg-accent/10' : 'border-accent/50 bg-white/[0.04]'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<!-- Circular mint icon -->
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-accent text-primary">
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
fill="currentColor"
|
||||||
|
points="6 12.5 16 2 26 12.5 24.5714286 14 16.999 6.049 17 30 15 30 14.999 6.051 7.42857143 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Serif title -->
|
||||||
|
<p class="font-serif text-base font-bold text-ink">{m.bulk_drop_hint()}</p>
|
||||||
|
|
||||||
|
<!-- Sub description -->
|
||||||
|
<p id="bulk-drop-desc" class="text-sm leading-relaxed text-ink-2">{m.bulk_drop_desc()}</p>
|
||||||
|
|
||||||
|
<!-- CTA button -->
|
||||||
|
<label
|
||||||
|
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-primary px-6 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
{m.bulk_select_files()}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="application/pdf"
|
||||||
|
class="sr-only"
|
||||||
|
onchange={(e) => {
|
||||||
|
const files = Array.from(e.currentTarget.files ?? []);
|
||||||
|
if (files.length > 0) onFilesAdded(files);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Format hint -->
|
||||||
|
<p class="text-xs text-ink-3">{m.bulk_drop_sub()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import BulkDropZone from './BulkDropZone.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('BulkDropZone', () => {
|
||||||
|
it('file input has multiple attribute', async () => {
|
||||||
|
const { container } = render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||||
|
const input = container.querySelector('input[type="file"]');
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
expect(input?.hasAttribute('multiple')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onFilesAdded with selected files when 3 files are picked via input', async () => {
|
||||||
|
const onFilesAdded = vi.fn();
|
||||||
|
render(BulkDropZone, { onFilesAdded });
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
new File(['a'], 'a.pdf', { type: 'application/pdf' }),
|
||||||
|
new File(['b'], 'b.pdf', { type: 'application/pdf' }),
|
||||||
|
new File(['c'], 'c.pdf', { type: 'application/pdf' })
|
||||||
|
];
|
||||||
|
|
||||||
|
const input = page.getByRole('button', { name: /Dateien auswählen/i });
|
||||||
|
await userEvent.upload(input, files);
|
||||||
|
|
||||||
|
expect(onFilesAdded).toHaveBeenCalledOnce();
|
||||||
|
const received: File[] = onFilesAdded.mock.calls[0][0];
|
||||||
|
expect(received).toHaveLength(3);
|
||||||
|
expect(received.map((f) => f.name)).toEqual(['a.pdf', 'b.pdf', 'c.pdf']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows drop hint text', async () => {
|
||||||
|
render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||||
|
await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
150
frontend/src/lib/components/document/FileSwitcherStrip.svelte
Normal file
150
frontend/src/lib/components/document/FileSwitcherStrip.svelte
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
title: string;
|
||||||
|
status: 'idle' | 'error';
|
||||||
|
previewUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
files,
|
||||||
|
activeId,
|
||||||
|
onSelect,
|
||||||
|
onRemove
|
||||||
|
}: {
|
||||||
|
files: FileEntry[];
|
||||||
|
activeId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let trackEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let listEl = $state<HTMLUListElement | null>(null);
|
||||||
|
|
||||||
|
const activeAnnouncement = $derived(files.find((f) => f.id === activeId)?.title ?? '');
|
||||||
|
|
||||||
|
function scrollPrev() {
|
||||||
|
trackEl?.scrollBy({ left: -120, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
function scrollNext() {
|
||||||
|
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(entry: FileEntry, index: number) {
|
||||||
|
const targetId = index > 0 ? files[index - 1].id : (files[index + 1]?.id ?? null);
|
||||||
|
onRemove(entry.id);
|
||||||
|
if (targetId) {
|
||||||
|
await tick();
|
||||||
|
(listEl?.querySelector<HTMLElement>(`[data-chip-id="${targetId}"]`) ?? null)?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!listEl) return;
|
||||||
|
const node = listEl;
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
const buttons = Array.from(node.querySelectorAll<HTMLElement>('[data-chip-id]'));
|
||||||
|
if (buttons.length === 0) return;
|
||||||
|
|
||||||
|
const focusedIndex = buttons.indexOf(document.activeElement as HTMLElement);
|
||||||
|
if (focusedIndex === -1) return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextIndex = (focusedIndex + 1) % buttons.length;
|
||||||
|
buttons[nextIndex].focus();
|
||||||
|
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
const prevIndex = (focusedIndex - 1 + buttons.length) % buttons.length;
|
||||||
|
buttons[prevIndex].focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => node.removeEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="sr-only">{activeAnnouncement}</div>
|
||||||
|
<div
|
||||||
|
data-testid="file-switcher-strip"
|
||||||
|
class="flex h-11 shrink-0 items-center gap-1 border-t border-line bg-pdf-ctrl px-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.bulk_switcher_prev()}
|
||||||
|
onclick={scrollPrev}
|
||||||
|
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>‹</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Gradient fade overlays signal hidden overflow to pointer-only users -->
|
||||||
|
<div class="relative flex flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-pdf-ctrl to-transparent"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-pdf-ctrl to-transparent"
|
||||||
|
></div>
|
||||||
|
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
||||||
|
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
|
||||||
|
{#each files as entry, i (entry.id)}
|
||||||
|
<li role="listitem" class="inline-flex shrink-0 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-current={entry.id === activeId ? 'true' : undefined}
|
||||||
|
data-status={entry.status}
|
||||||
|
data-chip-id={entry.id}
|
||||||
|
onclick={() => onSelect(entry.id)}
|
||||||
|
class={[
|
||||||
|
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-xs font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
||||||
|
entry.id === activeId
|
||||||
|
? 'bg-accent text-primary'
|
||||||
|
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
|
||||||
|
entry.status === 'error'
|
||||||
|
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
|
||||||
|
: ''
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={[
|
||||||
|
'rounded-[2px] px-0.5 text-[11px] font-extrabold opacity-85',
|
||||||
|
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
|
||||||
|
].join(' ')}
|
||||||
|
>{i + 1}</span
|
||||||
|
>
|
||||||
|
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
|
||||||
|
{#if entry.status === 'error'}
|
||||||
|
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
|
||||||
|
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.bulk_remove_file()}
|
||||||
|
data-remove-id={entry.id}
|
||||||
|
onclick={() => handleRemove(entry, i)}
|
||||||
|
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.bulk_switcher_next()}
|
||||||
|
onclick={scrollNext}
|
||||||
|
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>›</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||||
|
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function makeFiles(n: number): FileEntry[] {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: `id-${i}`,
|
||||||
|
file: new File([''], `file${i}.pdf`),
|
||||||
|
title: `File ${i}`,
|
||||||
|
status: 'idle' as const,
|
||||||
|
previewUrl: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FileSwitcherStrip', () => {
|
||||||
|
it('renders N chips for N files', async () => {
|
||||||
|
const files = makeFiles(4);
|
||||||
|
render(FileSwitcherStrip, {
|
||||||
|
files,
|
||||||
|
activeId: files[0].id,
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onRemove: vi.fn()
|
||||||
|
});
|
||||||
|
const chips = page.getByRole('listitem');
|
||||||
|
await expect.element(chips.nth(0)).toBeInTheDocument();
|
||||||
|
await expect.element(chips.nth(3)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active chip has aria-current="true"', async () => {
|
||||||
|
const files = makeFiles(3);
|
||||||
|
const { container } = render(FileSwitcherStrip, {
|
||||||
|
files,
|
||||||
|
activeId: files[1].id,
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onRemove: vi.fn()
|
||||||
|
});
|
||||||
|
const activeBtn = container.querySelector('[aria-current="true"]');
|
||||||
|
expect(activeBtn).not.toBeNull();
|
||||||
|
expect(activeBtn?.textContent).toContain('File 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a chip fires onSelect with its id', async () => {
|
||||||
|
const files = makeFiles(3);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const { container } = render(FileSwitcherStrip, {
|
||||||
|
files,
|
||||||
|
activeId: files[0].id,
|
||||||
|
onSelect,
|
||||||
|
onRemove: vi.fn()
|
||||||
|
});
|
||||||
|
const chip = container.querySelector('[data-chip-id="id-2"]') as HTMLElement;
|
||||||
|
expect(chip).not.toBeNull();
|
||||||
|
chip.click();
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('id-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error chip has aria-label containing warning indicator', async () => {
|
||||||
|
const files: FileEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'e1',
|
||||||
|
file: new File([''], 'bad.pdf'),
|
||||||
|
title: 'Bad file',
|
||||||
|
status: 'error',
|
||||||
|
previewUrl: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const { container } = render(FileSwitcherStrip, {
|
||||||
|
files,
|
||||||
|
activeId: 'e1',
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onRemove: vi.fn()
|
||||||
|
});
|
||||||
|
const errBtn = container.querySelector('[data-status="error"]');
|
||||||
|
expect(errBtn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error chip contains a screen-reader-only error label', async () => {
|
||||||
|
const files: FileEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'e1',
|
||||||
|
file: new File([''], 'bad.pdf'),
|
||||||
|
title: 'Bad file',
|
||||||
|
status: 'error',
|
||||||
|
previewUrl: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const { container } = render(FileSwitcherStrip, {
|
||||||
|
files,
|
||||||
|
activeId: 'e1',
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onRemove: vi.fn()
|
||||||
|
});
|
||||||
|
const errBtn = container.querySelector('[data-status="error"]');
|
||||||
|
const srOnly = errBtn?.querySelector('.sr-only');
|
||||||
|
expect(srOnly).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focus moves to the previous chip after the middle chip is removed', async () => {
|
||||||
|
const files = makeFiles(3); // id-0, id-1, id-2
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
const { container } = render(FileSwitcherStrip, {
|
||||||
|
files,
|
||||||
|
activeId: files[1].id,
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onRemove
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeBtn = container.querySelector('[data-remove-id="id-1"]') as HTMLButtonElement;
|
||||||
|
expect(removeBtn).not.toBeNull();
|
||||||
|
removeBtn.click();
|
||||||
|
expect(onRemove).toHaveBeenCalledWith('id-1');
|
||||||
|
|
||||||
|
// After removal, focus should be on the chip for id-0 (the previous chip)
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const prevChip = container.querySelector('[data-chip-id="id-0"]') as HTMLElement | null;
|
||||||
|
expect(prevChip).not.toBeNull();
|
||||||
|
expect(document.activeElement).toBe(prevChip);
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowRight moves focus to next chip without leaving strip', async () => {
|
||||||
|
const files = makeFiles(3);
|
||||||
|
const { container } = render(FileSwitcherStrip, {
|
||||||
|
files,
|
||||||
|
activeId: files[0].id,
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onRemove: vi.fn()
|
||||||
|
});
|
||||||
|
const firstBtn = container.querySelectorAll('[data-chip-id]')[0] as HTMLElement;
|
||||||
|
firstBtn.focus();
|
||||||
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
|
const focused = document.activeElement;
|
||||||
|
expect(focused).not.toBe(firstBtn);
|
||||||
|
// The new focused element should still be inside the strip
|
||||||
|
const strip = container.querySelector('[data-testid="file-switcher-strip"]');
|
||||||
|
expect(strip?.contains(focused)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
frontend/src/lib/components/document/ScopeCard.svelte
Normal file
40
frontend/src/lib/components/document/ScopeCard.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
variant,
|
||||||
|
count = 0,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
variant: 'per-file' | 'shared';
|
||||||
|
count?: number;
|
||||||
|
children?: import('svelte').Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="scope-card"
|
||||||
|
data-variant={variant}
|
||||||
|
class="mb-3 rounded-sm border p-4
|
||||||
|
{variant === 'per-file'
|
||||||
|
? 'border-accent bg-accent-bg'
|
||||||
|
: 'border-line bg-surface'}"
|
||||||
|
>
|
||||||
|
{#if variant === 'shared'}
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.bulk_scope_shared_label({ count })}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-xs font-bold text-primary"
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-3 text-xs font-bold tracking-widest text-primary uppercase">
|
||||||
|
{m.bulk_scope_per_file_label()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import ScopeCard from './ScopeCard.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('ScopeCard', () => {
|
||||||
|
it('per-file variant has accent background class', async () => {
|
||||||
|
const { container } = render(ScopeCard, { variant: 'per-file', count: 1 });
|
||||||
|
const card = container.querySelector('[data-testid="scope-card"]');
|
||||||
|
expect(card?.className).toMatch(/bg-accent-bg/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared variant does not have accent background', async () => {
|
||||||
|
const { container } = render(ScopeCard, { variant: 'shared', count: 3 });
|
||||||
|
const card = container.querySelector('[data-testid="scope-card"]');
|
||||||
|
expect(card?.className).not.toMatch(/bg-accent-bg/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared variant renders count badge with file count', async () => {
|
||||||
|
render(ScopeCard, { variant: 'shared', count: 5 });
|
||||||
|
await expect.element(page.getByText('5', { exact: true })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('per-file variant renders slot content', async () => {
|
||||||
|
// ScopeCard is a container — verify it renders children
|
||||||
|
render(ScopeCard, { variant: 'per-file', count: 1 });
|
||||||
|
const card = await page.getByTestId('scope-card');
|
||||||
|
await expect.element(card).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
frontend/src/lib/components/document/UploadSaveBar.svelte
Normal file
49
frontend/src/lib/components/document/UploadSaveBar.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
fileCount,
|
||||||
|
chunkProgress,
|
||||||
|
onSave,
|
||||||
|
onDiscard,
|
||||||
|
disabled = false
|
||||||
|
}: {
|
||||||
|
fileCount: number;
|
||||||
|
chunkProgress?: { done: number; total: number };
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void | Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="shrink-0 border-t border-line bg-surface px-4 py-3">
|
||||||
|
{#if chunkProgress}
|
||||||
|
<progress
|
||||||
|
value={chunkProgress.done}
|
||||||
|
max={chunkProgress.total}
|
||||||
|
aria-valuenow={chunkProgress.done}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={chunkProgress.total}
|
||||||
|
aria-label={m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
|
||||||
|
class="[&::-webkit-progress-bar]:bg-brand-sand mb-3 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
|
||||||
|
></progress>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onDiscard}
|
||||||
|
class="flex min-h-[44px] items-center px-2 text-sm text-red-600/70 hover:text-red-700"
|
||||||
|
>
|
||||||
|
{m.bulk_discard_all()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="bulk-save-btn"
|
||||||
|
disabled={fileCount === 0 || disabled}
|
||||||
|
onclick={onSave}
|
||||||
|
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('UploadSaveBar', () => {
|
||||||
|
it('shows plural label for multiple files', async () => {
|
||||||
|
render(UploadSaveBar, { fileCount: 5, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||||
|
// "5 speichern →" or similar plural form
|
||||||
|
await expect.element(page.getByText(/5/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows singular label for one file', async () => {
|
||||||
|
render(UploadSaveBar, { fileCount: 1, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||||
|
// "Speichern →" singular form
|
||||||
|
await expect.element(page.getByText(/Speichern/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('progress bar is visible when chunkProgress is provided', async () => {
|
||||||
|
const { container } = render(UploadSaveBar, {
|
||||||
|
fileCount: 3,
|
||||||
|
chunkProgress: { done: 1, total: 3 },
|
||||||
|
onSave: vi.fn(),
|
||||||
|
onDiscard: vi.fn()
|
||||||
|
});
|
||||||
|
const progress = container.querySelector('progress');
|
||||||
|
expect(progress).not.toBeNull();
|
||||||
|
expect(progress?.getAttribute('value')).toBe('1');
|
||||||
|
expect(progress?.getAttribute('max')).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('progress bar is not rendered when no chunkProgress', async () => {
|
||||||
|
const { container } = render(UploadSaveBar, {
|
||||||
|
fileCount: 2,
|
||||||
|
onSave: vi.fn(),
|
||||||
|
onDiscard: vi.fn()
|
||||||
|
});
|
||||||
|
const progress = container.querySelector('progress');
|
||||||
|
expect(progress).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard link is rendered', async () => {
|
||||||
|
render(UploadSaveBar, { fileCount: 2, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||||
|
await expect.element(page.getByText(/verwerfen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,8 +69,7 @@ $effect(() => {
|
|||||||
oninput={handleDateInput}
|
oninput={handleDateInput}
|
||||||
placeholder={m.form_placeholder_date()}
|
placeholder={m.form_placeholder_date()}
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
autofocus={!initialDateIso}
|
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm
|
|
||||||
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -89,7 +88,6 @@ $effect(() => {
|
|||||||
bind:value={senderId}
|
bind:value={senderId}
|
||||||
initialName={initialSenderName}
|
initialName={initialSenderName}
|
||||||
suggestedName={suggestedSenderName}
|
suggestedName={suggestedSenderName}
|
||||||
autofocus={!!initialDateIso}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +108,7 @@ $effect(() => {
|
|||||||
name="location"
|
name="location"
|
||||||
value={initialLocation}
|
value={initialLocation}
|
||||||
placeholder={m.form_placeholder_location()}
|
placeholder={m.form_placeholder_location()}
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type ErrorCode =
|
|||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
| 'VALIDATION_ERROR'
|
| 'VALIDATION_ERROR'
|
||||||
|
| 'BATCH_TOO_LARGE'
|
||||||
| 'INTERNAL_ERROR';
|
| 'INTERNAL_ERROR';
|
||||||
|
|
||||||
export interface BackendError {
|
export interface BackendError {
|
||||||
@@ -139,6 +140,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_forbidden();
|
return m.error_forbidden();
|
||||||
case 'VALIDATION_ERROR':
|
case 'VALIDATION_ERROR':
|
||||||
return m.error_validation_error();
|
return m.error_validation_error();
|
||||||
|
case 'BATCH_TOO_LARGE':
|
||||||
|
return m.error_batch_too_large();
|
||||||
default:
|
default:
|
||||||
return m.error_internal_error();
|
return m.error_internal_error();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,6 +548,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/generate-thumbnails": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["generateThumbnails"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/backfill-versions": {
|
"/api/admin/backfill-versions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1028,6 +1044,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{id}/thumbnail": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getDocumentThumbnail"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
|
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1204,6 +1236,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/thumbnail-status": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["thumbnailStatus"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/import-status": {
|
"/api/admin/import-status": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1390,7 +1438,6 @@ export interface components {
|
|||||||
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
|
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageCount?: number;
|
pageCount?: number;
|
||||||
thumbnailUrl?: string;
|
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||||
@@ -1413,6 +1460,7 @@ export interface components {
|
|||||||
sender?: components["schemas"]["Person"];
|
sender?: components["schemas"]["Person"];
|
||||||
tags?: components["schemas"]["Tag"][];
|
tags?: components["schemas"]["Tag"][];
|
||||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||||
|
thumbnailUrl?: string;
|
||||||
};
|
};
|
||||||
UpdateTranscriptionBlockDTO: {
|
UpdateTranscriptionBlockDTO: {
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -1639,6 +1687,17 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
DocumentBatchMetadataDTO: {
|
||||||
|
titles?: string[];
|
||||||
|
/** Format: uuid */
|
||||||
|
senderId?: string;
|
||||||
|
receiverIds?: string[];
|
||||||
|
/** Format: date */
|
||||||
|
documentDate?: string;
|
||||||
|
location?: string;
|
||||||
|
tagNames?: string[];
|
||||||
|
metadataComplete?: boolean;
|
||||||
|
};
|
||||||
QuickUploadResult: {
|
QuickUploadResult: {
|
||||||
created?: components["schemas"]["Document"][];
|
created?: components["schemas"]["Document"][];
|
||||||
updated?: components["schemas"]["Document"][];
|
updated?: components["schemas"]["Document"][];
|
||||||
@@ -1673,6 +1732,21 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
};
|
};
|
||||||
|
BackfillStatus: {
|
||||||
|
/** @enum {string} */
|
||||||
|
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
||||||
|
message?: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
total?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
processed?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
skipped?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
failed?: number;
|
||||||
|
/** Format: date-time */
|
||||||
|
startedAt?: string;
|
||||||
|
};
|
||||||
BackfillResult: {
|
BackfillResult: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
count: number;
|
count: number;
|
||||||
@@ -1837,10 +1911,10 @@ export interface components {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
};
|
};
|
||||||
PageNotificationDTO: {
|
PageNotificationDTO: {
|
||||||
/** Format: int32 */
|
|
||||||
totalPages?: number;
|
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
totalElements?: number;
|
totalElements?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
totalPages?: number;
|
||||||
pageable?: components["schemas"]["PageableObject"];
|
pageable?: components["schemas"]["PageableObject"];
|
||||||
first?: boolean;
|
first?: boolean;
|
||||||
last?: boolean;
|
last?: boolean;
|
||||||
@@ -3152,6 +3226,7 @@ export interface operations {
|
|||||||
content: {
|
content: {
|
||||||
"multipart/form-data": {
|
"multipart/form-data": {
|
||||||
files?: string[];
|
files?: string[];
|
||||||
|
metadata?: components["schemas"]["DocumentBatchMetadataDTO"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -3255,6 +3330,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
generateThumbnails: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["BackfillStatus"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
backfillVersions: {
|
backfillVersions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3975,6 +4070,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getDocumentThumbnail: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getBlockHistory: {
|
getBlockHistory: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4038,15 +4155,9 @@ export interface operations {
|
|||||||
dir?: string;
|
dir?: string;
|
||||||
/** @description Tag operator: AND (default) or OR */
|
/** @description Tag operator: AND (default) or OR */
|
||||||
tagOp?: string;
|
tagOp?: string;
|
||||||
/**
|
/** @description Page number (0-indexed) */
|
||||||
* @description Page number (0-indexed)
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
page?: number;
|
page?: number;
|
||||||
/**
|
/** @description Page size (max 100) */
|
||||||
* @description Page size (max 100)
|
|
||||||
* @default 50
|
|
||||||
*/
|
|
||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -4245,6 +4356,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
thumbnailStatus: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["BackfillStatus"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
importStatus: {
|
importStatus: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseFilename, stripExtension } from './filename';
|
import { parseFilename, stripExtension, bulkTitleFromFilename } from './filename';
|
||||||
|
|
||||||
describe('parseFilename', () => {
|
describe('parseFilename', () => {
|
||||||
describe('date-first patterns', () => {
|
describe('date-first patterns', () => {
|
||||||
@@ -86,6 +86,24 @@ describe('parseFilename', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('bulkTitleFromFilename', () => {
|
||||||
|
it('replaces underscores with spaces', () => {
|
||||||
|
expect(bulkTitleFromFilename('hello_world.pdf')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces hyphens with spaces', () => {
|
||||||
|
expect(bulkTitleFromFilename('2024-01-01_Max.pdf')).toBe('2024 01 01 Max');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses multiple separators', () => {
|
||||||
|
expect(bulkTitleFromFilename('foo__bar--baz.pdf')).toBe('foo bar baz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips extension', () => {
|
||||||
|
expect(bulkTitleFromFilename('document.pdf')).toBe('document');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('stripExtension', () => {
|
describe('stripExtension', () => {
|
||||||
it('removes the extension', () => {
|
it('removes the extension', () => {
|
||||||
expect(stripExtension('document.pdf')).toBe('document');
|
expect(stripExtension('document.pdf')).toBe('document');
|
||||||
|
|||||||
@@ -81,3 +81,7 @@ export function parseFilename(filename: string): FilenameParseResult {
|
|||||||
export function stripExtension(filename: string): string {
|
export function stripExtension(filename: string): string {
|
||||||
return filename.replace(/\.[^/.]+$/, '');
|
return filename.replace(/\.[^/.]+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bulkTitleFromFilename(filename: string): string {
|
||||||
|
return stripExtension(filename).replace(/[_-]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,154 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import BulkDocumentEditLayout from '$lib/components/document/BulkDocumentEditLayout.svelte';
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
|
||||||
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
|
|
||||||
import TranscriptionSection from '$lib/components/document/TranscriptionSection.svelte';
|
|
||||||
import FileSectionNew from './FileSectionNew.svelte';
|
|
||||||
import { type FilenameParseResult } from '$lib/utils/filename';
|
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let tags: { name: string; id?: string; color?: string; parentId?: string }[] = $state([]);
|
|
||||||
let senderId = $state(untrack(() => data.initialSenderId));
|
|
||||||
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
|
|
||||||
$state(untrack(() => data.initialReceivers));
|
|
||||||
|
|
||||||
let parsedSuggestion = $state<FilenameParseResult>({});
|
|
||||||
|
|
||||||
// Title is derived from the filename suggestion unless the user has typed something
|
|
||||||
let titleDirty = $state(false);
|
|
||||||
let titleOverride = $state('');
|
|
||||||
let titleValue = $derived(
|
|
||||||
titleDirty ? titleOverride : (parsedSuggestion.suggestedTitle ?? titleOverride)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Details panel: starts open when prefill data is present or a form error occurred.
|
|
||||||
// Auto-opens when filename parsing finds a date/sender, but never force-closes — user
|
|
||||||
// can always collapse the section manually.
|
|
||||||
let detailsOpen = $state(
|
|
||||||
!!(
|
|
||||||
untrack(() => data.initialSenderId) ||
|
|
||||||
untrack(() => data.initialReceivers).length > 0 ||
|
|
||||||
untrack(() => form)?.error
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (parsedSuggestion.dateIso || senderId || selectedReceivers.length > 0) {
|
|
||||||
detailsOpen = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
<BulkDocumentEditLayout
|
||||||
<!-- Heading -->
|
initialSenderId={data.initialSenderId}
|
||||||
<div class="mb-6">
|
initialSenderName={data.initialSenderName}
|
||||||
<a
|
initialReceivers={data.initialReceivers}
|
||||||
href="/"
|
/>
|
||||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{m.btn_back_to_overview()}
|
|
||||||
</a>
|
|
||||||
<h1 class="font-serif text-3xl text-ink">{m.doc_new_heading()}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if form?.error}
|
|
||||||
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
|
||||||
<!-- File upload — prominent, at the top -->
|
|
||||||
<FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
|
|
||||||
|
|
||||||
<!-- Standalone title card -->
|
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
|
||||||
<label for="new-title" class="mb-1 block text-sm font-medium text-ink-2"
|
|
||||||
>{m.form_label_title()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="new-title"
|
|
||||||
type="text"
|
|
||||||
name="title"
|
|
||||||
value={titleValue}
|
|
||||||
oninput={(e) => {
|
|
||||||
titleOverride = (e.target as HTMLInputElement).value;
|
|
||||||
titleDirty = true;
|
|
||||||
}}
|
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
placeholder="Titel eingeben…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Collapsible further details -->
|
|
||||||
<details
|
|
||||||
bind:open={detailsOpen}
|
|
||||||
class="group rounded-sm border border-line bg-surface shadow-sm"
|
|
||||||
>
|
|
||||||
<summary class="cursor-pointer list-none px-6 py-4">
|
|
||||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.doc_more_details()}</span
|
|
||||||
>
|
|
||||||
</summary>
|
|
||||||
<div class="space-y-6 px-0 pb-6">
|
|
||||||
<WhoWhenSection
|
|
||||||
bind:senderId={senderId}
|
|
||||||
bind:selectedReceivers={selectedReceivers}
|
|
||||||
initialSenderName={data.initialSenderName}
|
|
||||||
suggestedDateIso={parsedSuggestion.dateIso ?? ''}
|
|
||||||
suggestedSenderName={parsedSuggestion.personName ?? ''}
|
|
||||||
/>
|
|
||||||
<DescriptionSection bind:tags={tags} hideTitle={true} />
|
|
||||||
<TranscriptionSection />
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Sticky Save Bar -->
|
|
||||||
<div
|
|
||||||
class="sticky bottom-0 z-10 -mx-4 border-t border-line bg-surface px-4 py-3 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6 sm:py-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="order-last text-center text-sm font-medium text-ink-2 transition-colors hover:text-ink sm:order-first sm:text-left"
|
|
||||||
>
|
|
||||||
{m.btn_cancel()}
|
|
||||||
</a>
|
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
name="metadataComplete"
|
|
||||||
value="false"
|
|
||||||
formaction="?/save"
|
|
||||||
class="w-full rounded-sm border border-line px-5 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted sm:w-auto sm:py-2"
|
|
||||||
>
|
|
||||||
{m.btn_save()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
name="metadataComplete"
|
|
||||||
value="true"
|
|
||||||
formaction="?/saveReviewed"
|
|
||||||
class="w-full rounded-sm bg-primary px-5 py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90 sm:w-auto sm:py-2"
|
|
||||||
>
|
|
||||||
{m.btn_save_and_mark_reviewed()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -21,15 +21,14 @@ const baseData = {
|
|||||||
|
|
||||||
describe('New document page – sender prefill', () => {
|
describe('New document page – sender prefill', () => {
|
||||||
it('shows an empty sender input when no senderId is in the URL', async () => {
|
it('shows an empty sender input when no senderId is in the URL', async () => {
|
||||||
render(Page, { data: baseData, form: null });
|
render(Page, { data: baseData });
|
||||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||||
expect(input?.value).toBe('');
|
expect(input?.value).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
|
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
||||||
form: null
|
|
||||||
});
|
});
|
||||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||||
expect(input?.value).toBe('Hans Müller');
|
expect(input?.value).toBe('Hans Müller');
|
||||||
@@ -37,8 +36,7 @@ describe('New document page – sender prefill', () => {
|
|||||||
|
|
||||||
it('sets the hidden senderId input to the prefilled ID', async () => {
|
it('sets the hidden senderId input to the prefilled ID', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
||||||
form: null
|
|
||||||
});
|
});
|
||||||
const hidden = document.querySelector<HTMLInputElement>(
|
const hidden = document.querySelector<HTMLInputElement>(
|
||||||
'input[type="hidden"][name="senderId"]'
|
'input[type="hidden"][name="senderId"]'
|
||||||
@@ -51,7 +49,7 @@ describe('New document page – sender prefill', () => {
|
|||||||
|
|
||||||
describe('New document page – receiver prefill', () => {
|
describe('New document page – receiver prefill', () => {
|
||||||
it('shows no receiver chips when initialReceivers is empty', async () => {
|
it('shows no receiver chips when initialReceivers is empty', async () => {
|
||||||
render(Page, { data: baseData, form: null });
|
render(Page, { data: baseData });
|
||||||
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +60,7 @@ describe('New document page – receiver prefill', () => {
|
|||||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
render(Page, { data, form: null });
|
render(Page, { data });
|
||||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +71,7 @@ describe('New document page – receiver prefill', () => {
|
|||||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
render(Page, { data, form: null });
|
render(Page, { data });
|
||||||
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
||||||
expect(hidden?.value).toBe('p2');
|
expect(hidden?.value).toBe('p2');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user