Compare commits
19 Commits
0db68da00c
...
feat/81-di
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d0a2a2c9c | ||
|
|
0f0d74eb2f | ||
|
|
20f6de4424 | ||
|
|
bf82ebfe1d | ||
|
|
c6984e49ee | ||
|
|
150bc2f171 | ||
|
|
41c311249b | ||
|
|
2efa790243 | ||
|
|
648bdffe4f | ||
|
|
99e3163c0e | ||
|
|
f0940524e7 | ||
|
|
a302f96560 | ||
|
|
654e736f8a | ||
|
|
078bc1c886 | ||
|
|
8555193a79 | ||
|
|
aab9e9a4b0 | ||
|
|
0ce18e1eed | ||
|
|
2bfbf45eba | ||
|
|
40f01a7712 |
@@ -4,6 +4,8 @@ import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -156,6 +158,23 @@ public class DocumentController {
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete-count")
|
||||
public Map<String, Long> getIncompleteCount() {
|
||||
return Map.of("count", documentService.getIncompleteCount());
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete")
|
||||
public List<Document> getIncomplete() {
|
||||
return documentService.findIncompleteDocuments();
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete/next")
|
||||
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||
return documentService.findNextIncompleteDocument(excludeId)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<Document>> search(
|
||||
@RequestParam(required = false) String q,
|
||||
|
||||
@@ -17,4 +17,5 @@ public class DocumentUpdateDTO {
|
||||
private UUID senderId;
|
||||
private List<UUID> receiverIds;
|
||||
private String tags;
|
||||
private Boolean metadataComplete;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,11 @@ public class Document {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column(name = "metadata_complete", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private boolean metadataComplete = false;
|
||||
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||
@Builder.Default
|
||||
|
||||
@@ -42,6 +42,12 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||
|
||||
long countByMetadataCompleteFalse();
|
||||
|
||||
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||
|
||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"JOIN d.receivers r " +
|
||||
"WHERE " +
|
||||
|
||||
@@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
|
||||
// Exact first+last name match, used for filename-based sender lookup
|
||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||
|
||||
// --- Correspondent queries ---
|
||||
|
||||
@Query(value = """
|
||||
|
||||
@@ -62,10 +62,18 @@ public class DocumentService {
|
||||
if (existingDoc.isPresent()) {
|
||||
document = existingDoc.get();
|
||||
} else {
|
||||
// New uploads from the drop zone always start as incomplete
|
||||
ParsedFilename parsed = parseFilenameData(originalFilename);
|
||||
Person sender = (parsed != null)
|
||||
? personService.findByName(parsed.firstName(), parsed.lastName()).orElse(null)
|
||||
: null;
|
||||
document = Document.builder()
|
||||
.originalFilename(originalFilename)
|
||||
.title(stripExtension(originalFilename))
|
||||
.title(parsed != null ? parsed.title() : stripExtension(originalFilename))
|
||||
.documentDate(parsed != null ? parsed.date() : null)
|
||||
.sender(sender)
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metadataComplete(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -89,6 +97,17 @@ public class DocumentService {
|
||||
? file.getOriginalFilename()
|
||||
: (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument");
|
||||
|
||||
// If the caller explicitly sets metadataComplete, use it.
|
||||
// Otherwise apply heuristic: complete if at least one key field is present.
|
||||
boolean metadataComplete;
|
||||
if (dto.getMetadataComplete() != null) {
|
||||
metadataComplete = dto.getMetadataComplete();
|
||||
} else {
|
||||
metadataComplete = dto.getDocumentDate() != null
|
||||
|| dto.getSenderId() != null
|
||||
|| (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty());
|
||||
}
|
||||
|
||||
Document doc = Document.builder()
|
||||
.originalFilename(filename)
|
||||
.title(dto.getTitle())
|
||||
@@ -98,6 +117,7 @@ public class DocumentService {
|
||||
.transcription(dto.getTranscription())
|
||||
.summary(dto.getSummary())
|
||||
.status(DocumentStatus.PLACEHOLDER)
|
||||
.metadataComplete(metadataComplete)
|
||||
.build();
|
||||
|
||||
doc = documentRepository.save(doc);
|
||||
@@ -176,6 +196,11 @@ public class DocumentService {
|
||||
doc.getReceivers().clear(); // Alle entfernen
|
||||
}
|
||||
|
||||
// 3b. metadataComplete — only update when explicitly set in the DTO
|
||||
if (dto.getMetadataComplete() != null) {
|
||||
doc.setMetadataComplete(dto.getMetadataComplete());
|
||||
}
|
||||
|
||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||
if (newFile != null && !newFile.isEmpty()) {
|
||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
@@ -280,6 +305,19 @@ public class DocumentService {
|
||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
||||
}
|
||||
|
||||
public long getIncompleteCount() {
|
||||
return documentRepository.countByMetadataCompleteFalse();
|
||||
}
|
||||
|
||||
public List<Document> findIncompleteDocuments() {
|
||||
return documentRepository.findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
}
|
||||
|
||||
public Optional<Document> findNextIncompleteDocument(UUID currentId) {
|
||||
return documentRepository.findFirstByMetadataCompleteFalseAndIdNot(
|
||||
currentId, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteDocument(UUID id) {
|
||||
if (!documentRepository.existsById(id)) {
|
||||
@@ -324,6 +362,81 @@ public class DocumentService {
|
||||
return dot > 0 ? filename.substring(0, dot) : filename;
|
||||
}
|
||||
|
||||
private record ParsedFilename(LocalDate date, String firstName, String lastName) {
|
||||
String title() {
|
||||
String dateDisplay = String.format("%02d.%02d.%d",
|
||||
date.getDayOfMonth(), date.getMonthValue(), date.getYear());
|
||||
return firstName + " " + lastName + " (" + dateDisplay + ")";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a structured filename into its date and name components.
|
||||
*
|
||||
* Algorithm: split stem on "_", identify the date token (first or last segment),
|
||||
* treat the outermost remaining segment as firstName, rest as lastName parts.
|
||||
* Compound last names (e.g. "de_Gruyter") are supported naturally.
|
||||
* Returns null for unrecognised filenames.
|
||||
*
|
||||
* Examples:
|
||||
* 18881025_de_Gruyter_Walter.pdf → date=1888-10-25, firstName=Walter, lastName=de Gruyter
|
||||
* 1965-03-12_Mueller_Hans.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
|
||||
* Mueller_Hans_19650312.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
|
||||
*/
|
||||
private static ParsedFilename parseFilenameData(String filename) {
|
||||
if (filename == null) return null;
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot < 0) return null;
|
||||
String stem = filename.substring(0, dot);
|
||||
|
||||
String[] parts = stem.split("_", -1);
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
String dateIso;
|
||||
String[] nameParts;
|
||||
|
||||
String dateFromFirst = tryParseDate(parts[0]);
|
||||
if (dateFromFirst != null) {
|
||||
dateIso = dateFromFirst;
|
||||
nameParts = Arrays.copyOfRange(parts, 1, parts.length);
|
||||
} else {
|
||||
String dateFromLast = tryParseDate(parts[parts.length - 1]);
|
||||
if (dateFromLast == null) return null;
|
||||
dateIso = dateFromLast;
|
||||
nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
|
||||
}
|
||||
|
||||
if (nameParts.length < 2) return null;
|
||||
for (String p : nameParts) {
|
||||
if (!p.matches("\\p{L}+")) return null;
|
||||
}
|
||||
|
||||
String firstName = nameParts[nameParts.length - 1];
|
||||
String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, nameParts.length - 1));
|
||||
return new ParsedFilename(LocalDate.parse(dateIso), firstName, lastName);
|
||||
}
|
||||
|
||||
// Used by tests and as a public utility; delegates to parseFilenameData.
|
||||
static String titleFromFilename(String filename) {
|
||||
if (filename == null) return null;
|
||||
ParsedFilename parsed = parseFilenameData(filename);
|
||||
return parsed != null ? parsed.title() : stripExtension(filename);
|
||||
}
|
||||
|
||||
private static String tryParseDate(String s) {
|
||||
if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
|
||||
int m = Integer.parseInt(s.substring(5, 7));
|
||||
int d = Integer.parseInt(s.substring(8, 10));
|
||||
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
|
||||
} else if (s.matches("\\d{8}")) {
|
||||
int m = Integer.parseInt(s.substring(4, 6));
|
||||
int d = Integer.parseInt(s.substring(6, 8));
|
||||
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
|
||||
return s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -312,6 +312,9 @@ public class MassImportService {
|
||||
.originalFilename(originalFilename)
|
||||
.build());
|
||||
|
||||
// Heuristic: mark as complete if at least one key field is present in the spreadsheet row
|
||||
boolean metadataComplete = date != null || !senderRaw.isBlank() || !receiversRaw.isBlank();
|
||||
|
||||
doc.setTitle(buildTitle(index, date, location));
|
||||
doc.setFilePath(s3Key);
|
||||
doc.setContentType(contentType);
|
||||
@@ -325,6 +328,7 @@ public class MassImportService {
|
||||
doc.setSender(sender);
|
||||
doc.getReceivers().addAll(receivers);
|
||||
if (tag != null) doc.getTags().add(tag);
|
||||
doc.setMetadataComplete(metadataComplete);
|
||||
|
||||
documentRepository.save(doc);
|
||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
@@ -42,6 +43,10 @@ public class PersonService {
|
||||
return personRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public Optional<Person> findByName(String firstName, String lastName) {
|
||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person findOrCreateByAlias(String rawName) {
|
||||
String alias = rawName.trim();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add metadata_complete flag to documents.
|
||||
-- Existing rows default to true (already reviewed before this feature existed).
|
||||
-- New documents created via Java will receive false from the entity default.
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN metadata_complete BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -21,6 +21,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -212,6 +213,78 @@ class DocumentControllerTest {
|
||||
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getIncompleteCount_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncompleteCount_returns200_withCount() throws Exception {
|
||||
when(documentService.getIncompleteCount()).thenReturn(3L);
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_returns200_withList() throws Exception {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Unvollständig").originalFilename("scan.pdf").build();
|
||||
when(documentService.findIncompleteDocuments()).thenReturn(List.of(doc));
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getNextIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||
.param("excludeId", UUID.randomUUID().toString()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getNextIncomplete_returns200_whenNextExists() throws Exception {
|
||||
UUID excludeId = UUID.randomUUID();
|
||||
Document next = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Nächster").originalFilename("next.pdf").build();
|
||||
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.of(next));
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||
.param("excludeId", excludeId.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").value("Nächster"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
|
||||
UUID excludeId = UUID.randomUUID();
|
||||
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||
.param("excludeId", excludeId.toString()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -9,9 +10,13 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -344,6 +349,209 @@ class DocumentServiceTest {
|
||||
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
|
||||
}
|
||||
|
||||
// ─── getIncompleteCount ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getIncompleteCount_delegatesToRepository() {
|
||||
when(documentRepository.countByMetadataCompleteFalse()).thenReturn(5L);
|
||||
assertThat(documentService.getIncompleteCount()).isEqualTo(5L);
|
||||
}
|
||||
|
||||
// ─── findIncompleteDocuments ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() {
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
|
||||
when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc));
|
||||
|
||||
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
|
||||
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
}
|
||||
|
||||
// ─── findNextIncompleteDocument ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findNextIncompleteDocument_returnsNext_whenAnotherIncompleteExists() {
|
||||
UUID currentId = UUID.randomUUID();
|
||||
Document next = Document.builder().id(UUID.randomUUID()).title("Next").build();
|
||||
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
|
||||
.thenReturn(Optional.of(next));
|
||||
|
||||
assertThat(documentService.findNextIncompleteDocument(currentId)).contains(next);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findNextIncompleteDocument_returnsEmpty_whenNoMoreIncomplete() {
|
||||
UUID currentId = UUID.randomUUID();
|
||||
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThat(documentService.findNextIncompleteDocument(currentId)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── storeDocument metadataComplete ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void storeDocument_setsMetadataCompleteFalse_forNewDocument() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf").build();
|
||||
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().isMetadataComplete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_doesNotChangeMetadataComplete_forExistingDocument() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).metadataComplete(true).build();
|
||||
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
|
||||
documentService.storeDocument(file);
|
||||
|
||||
assertThat(existing.isMetadataComplete()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_parsesDateFromFilename_forNewDocument() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
|
||||
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
when(personService.findByName(any(), any())).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getDocumentDate()).isEqualTo(java.time.LocalDate.of(1965, 3, 12));
|
||||
assertThat(captor.getValue().getTitle()).isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_setsSender_whenPersonExistsForParsedName() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "18881025_de_Gruyter_Walter.pdf", "application/pdf", new byte[]{1});
|
||||
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("de Gruyter").build();
|
||||
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
when(personService.findByName("Walter", "de Gruyter")).thenReturn(Optional.of(walter));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSender()).isEqualTo(walter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_leavesSenderNull_whenPersonNotFound() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
|
||||
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
when(personService.findByName(any(), any())).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSender()).isNull();
|
||||
}
|
||||
|
||||
// ─── createDocument metadataComplete ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createDocument_setsMetadataCompleteFromDto_whenExplicitlyProvided() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Doc");
|
||||
dto.setMetadataComplete(true);
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_setsMetadataCompleteFalse_whenAllKeyFieldsMissingAndNoExplicitFlag() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Doc");
|
||||
// no documentDate, no senderId, no receiverIds, no metadataComplete flag
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_setsMetadataCompleteTrue_whenDatePresentAndNoExplicitFlag() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Doc");
|
||||
dto.setDocumentDate(LocalDate.of(2020, 1, 1));
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
|
||||
}
|
||||
|
||||
// ─── updateDocument metadataComplete ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateDocument_setsMetadataComplete_whenDtoHasValue() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setMetadataComplete(true);
|
||||
documentService.updateDocument(id, dto, null);
|
||||
|
||||
assertThat(existing.isMetadataComplete()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_doesNotChangeMetadataComplete_whenDtoHasNull() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
// metadataComplete not set → null
|
||||
documentService.updateDocument(id, dto, null);
|
||||
|
||||
assertThat(existing.isMetadataComplete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
@@ -358,4 +566,52 @@ class DocumentServiceTest {
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
// ─── titleFromFilename ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void titleFromFilename_dateIso_name() {
|
||||
assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller_Hans.pdf"))
|
||||
.isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_dateCompact_name() {
|
||||
assertThat(DocumentService.titleFromFilename("19650312_Mueller_Hans.pdf"))
|
||||
.isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_name_dateIso() {
|
||||
assertThat(DocumentService.titleFromFilename("Mueller_Hans_1965-03-12.pdf"))
|
||||
.isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_name_dateCompact() {
|
||||
assertThat(DocumentService.titleFromFilename("Mueller_Hans_19650312.pdf"))
|
||||
.isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_compound_lastName_dateFirst() {
|
||||
assertThat(DocumentService.titleFromFilename("18881025_de_Gruyter_Walter.pdf"))
|
||||
.isEqualTo("Walter de Gruyter (25.10.1888)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_compound_lastName_dateLast() {
|
||||
assertThat(DocumentService.titleFromFilename("de_Gruyter_Walter_18881025.pdf"))
|
||||
.isEqualTo("Walter de Gruyter (25.10.1888)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_fallsBackToStripExtension() {
|
||||
assertThat(DocumentService.titleFromFilename("scan_001.pdf")).isEqualTo("scan_001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_null_returnsNull() {
|
||||
assertThat(DocumentService.titleFromFilename(null)).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +268,30 @@
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||
"pdf_annotations_hide": "Annotierungen verbergen",
|
||||
"upload_drop_hint": "Dateien ablegen oder auswählen",
|
||||
"upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt",
|
||||
"upload_success": "{count} Dokument(e) erstellt",
|
||||
"upload_duplicate": "{filename} existiert bereits —",
|
||||
"upload_duplicate_link": "Zum Dokument",
|
||||
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
|
||||
"upload_error": "Fehler beim Hochladen von {filename}"
|
||||
"upload_error": "Fehler beim Hochladen von {filename}",
|
||||
"enrich_list_back": "Zurück zur Übersicht",
|
||||
"enrich_list_count": "Dokumente",
|
||||
"btn_save_and_mark_reviewed": "Speichern & abschließen",
|
||||
"btn_mark_for_review": "Zur Überprüfung markieren",
|
||||
"enrich_needs_metadata_title": "Dokumente ohne Metadaten",
|
||||
"enrich_needs_metadata_count": "{count} Dokument(e) warten auf Metadaten",
|
||||
"enrich_needs_metadata_cta": "Jetzt vervollständigen",
|
||||
"enrich_list_heading": "Dokumente ohne Metadaten",
|
||||
"enrich_list_empty_heading": "Alle Dokumente vollständig",
|
||||
"enrich_list_empty_body": "Es gibt keine Dokumente, die noch Metadaten benötigen.",
|
||||
"enrich_list_start": "Überprüfung starten",
|
||||
"enrich_progress": "{count} verbleibend",
|
||||
"enrich_skip": "Überspringen",
|
||||
"enrich_done_heading": "Alles erledigt!",
|
||||
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
|
||||
"enrich_back_to_list": "Zurück zur Liste",
|
||||
"comment_empty_hint": "Noch keine Kommentare – starte die Diskussion!",
|
||||
"comment_start_discussion": "Diskussion starten →"
|
||||
}
|
||||
|
||||
@@ -268,11 +268,30 @@
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||
"pdf_annotations_show": "Show annotations",
|
||||
"pdf_annotations_hide": "Hide annotations",
|
||||
"upload_drop_hint": "Drop files or click to select",
|
||||
"upload_drop_hint": "Drop one or multiple files at once",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled",
|
||||
"upload_success": "{count} document(s) created",
|
||||
"upload_duplicate": "{filename} already exists —",
|
||||
"upload_duplicate_link": "View document",
|
||||
"upload_invalid_type": "{filename}: unsupported file format",
|
||||
"upload_error": "Error uploading {filename}"
|
||||
"upload_error": "Error uploading {filename}",
|
||||
"enrich_list_back": "Back to overview",
|
||||
"enrich_list_count": "documents",
|
||||
"btn_save_and_mark_reviewed": "Save & mark as reviewed",
|
||||
"btn_mark_for_review": "Mark for review",
|
||||
"enrich_needs_metadata_title": "Documents without metadata",
|
||||
"enrich_needs_metadata_count": "{count} document(s) waiting for metadata",
|
||||
"enrich_needs_metadata_cta": "Complete now",
|
||||
"enrich_list_heading": "Documents without metadata",
|
||||
"enrich_list_empty_heading": "All documents complete",
|
||||
"enrich_list_empty_body": "There are no documents that still need metadata.",
|
||||
"enrich_list_start": "Start reviewing",
|
||||
"enrich_progress": "{count} remaining",
|
||||
"enrich_skip": "Skip",
|
||||
"enrich_done_heading": "All done!",
|
||||
"enrich_done_body": "All documents have been processed.",
|
||||
"enrich_back_to_list": "Back to list",
|
||||
"comment_empty_hint": "No comments yet – start the discussion!",
|
||||
"comment_start_discussion": "Start discussion →"
|
||||
}
|
||||
|
||||
@@ -268,11 +268,30 @@
|
||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||
"pdf_annotations_show": "Mostrar anotaciones",
|
||||
"pdf_annotations_hide": "Ocultar anotaciones",
|
||||
"upload_drop_hint": "Soltar archivos o hacer clic para seleccionar",
|
||||
"upload_drop_hint": "Uno o varios archivos a la vez",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados",
|
||||
"upload_success": "{count} documento(s) creado(s)",
|
||||
"upload_duplicate": "{filename} ya existe —",
|
||||
"upload_duplicate_link": "Ver documento",
|
||||
"upload_invalid_type": "{filename}: formato de archivo no admitido",
|
||||
"upload_error": "Error al subir {filename}"
|
||||
"upload_error": "Error al subir {filename}",
|
||||
"enrich_list_back": "Volver a la vista general",
|
||||
"enrich_list_count": "documentos",
|
||||
"btn_save_and_mark_reviewed": "Guardar y marcar como revisado",
|
||||
"btn_mark_for_review": "Marcar para revisión",
|
||||
"enrich_needs_metadata_title": "Documentos sin metadatos",
|
||||
"enrich_needs_metadata_count": "{count} documento(s) esperando metadatos",
|
||||
"enrich_needs_metadata_cta": "Completar ahora",
|
||||
"enrich_list_heading": "Documentos sin metadatos",
|
||||
"enrich_list_empty_heading": "Todos los documentos completos",
|
||||
"enrich_list_empty_body": "No hay documentos que necesiten metadatos.",
|
||||
"enrich_list_start": "Comenzar revisión",
|
||||
"enrich_progress": "{count} restante(s)",
|
||||
"enrich_skip": "Omitir",
|
||||
"enrich_done_heading": "¡Todo listo!",
|
||||
"enrich_done_body": "Todos los documentos han sido procesados.",
|
||||
"enrich_back_to_list": "Volver a la lista",
|
||||
"comment_empty_hint": "Aún no hay comentarios – ¡inicia la discusión!",
|
||||
"comment_start_discussion": "Iniciar discusión →"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<script lang="ts">
|
||||
type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
};
|
||||
import type { Annotation } from '$lib/types';
|
||||
|
||||
type DrawRect = {
|
||||
x: number;
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type CommentReply = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: CommentReply[];
|
||||
};
|
||||
import type { Comment, CommentReply } from '$lib/types';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
@@ -185,158 +167,120 @@ function cancelReply() {
|
||||
onMount(() => {
|
||||
if (loadOnMount) {
|
||||
reload();
|
||||
} else {
|
||||
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||
onCountChange?.(total);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Renders a single comment or reply entry.
|
||||
showReplyButton: whether the "Reply" button appears (only on last item in a thread).
|
||||
-->
|
||||
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
||||
{#if editingId === comment.id}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
rows={3}
|
||||
bind:value={editText}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => saveEdit(comment.id)}
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={cancelEdit}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-sans text-xs font-semibold text-ink">{comment.authorName}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
|
||||
{#if wasEdited(comment)}
|
||||
<span class="font-sans text-xs text-ink-3">
|
||||
{m.comment_edited_label()}
|
||||
{timeAgo(comment.updatedAt)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{comment.content}</p>
|
||||
</div>
|
||||
{#if canModify(comment)}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => startEdit(comment)}
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showReplyButton && canComment}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
|
||||
onclick={() => startReply(threadId)}
|
||||
>
|
||||
{m.comment_btn_reply()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if comments.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<svg
|
||||
class="h-10 w-10 text-ink-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#each comments as thread, ti (thread.id)}
|
||||
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
||||
<!-- Root comment -->
|
||||
<div>
|
||||
{#if editingId === thread.id}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
rows={3}
|
||||
bind:value={editText}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => saveEdit(thread.id)}
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={cancelEdit}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-sans text-xs font-semibold text-ink">{thread.authorName}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{timeAgo(thread.createdAt)}</span>
|
||||
{#if wasEdited(thread)}
|
||||
<span class="font-sans text-xs text-ink-3">
|
||||
{m.comment_edited_label()}
|
||||
{timeAgo(thread.updatedAt)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{thread.content}</p>
|
||||
</div>
|
||||
{#if canModify(thread)}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => startEdit(thread)}
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => deleteComment(thread.id)}
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Reply button on root comment only if there are no replies -->
|
||||
{#if thread.replies.length === 0 && canComment}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
|
||||
onclick={() => startReply(thread.id)}
|
||||
>
|
||||
{m.comment_btn_reply()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{#each thread.replies as reply, ri (reply.id)}
|
||||
<div class="mt-3 ml-6 border-l-2 border-line pl-4">
|
||||
{#if editingId === reply.id}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
rows={3}
|
||||
bind:value={editText}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => saveEdit(reply.id)}
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={cancelEdit}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-sans text-xs font-semibold text-ink">{reply.authorName}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{timeAgo(reply.createdAt)}</span>
|
||||
{#if wasEdited(reply)}
|
||||
<span class="font-sans text-xs text-ink-3">
|
||||
{m.comment_edited_label()}
|
||||
{timeAgo(reply.updatedAt)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{reply.content}</p>
|
||||
</div>
|
||||
{#if canModify(reply)}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => startEdit(reply)}
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => deleteComment(reply.id)}
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Reply button only on the last reply -->
|
||||
{#if ri === thread.replies.length - 1 && canComment}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
|
||||
onclick={() => startReply(thread.id)}
|
||||
>
|
||||
{m.comment_btn_reply()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Reply textarea (shown when replyingTo === thread.id) -->
|
||||
<!-- Reply compose box -->
|
||||
{#if replyingTo === thread.id}
|
||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||
<textarea
|
||||
@@ -365,7 +309,7 @@ onMount(() => {
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- New top-level comment textarea -->
|
||||
<!-- New top-level comment -->
|
||||
{#if canComment}
|
||||
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
70
frontend/src/lib/components/CommentThread.svelte.spec.ts
Normal file
70
frontend/src/lib/components/CommentThread.svelte.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function makeComment(id: string, content = 'Hello'): Comment {
|
||||
return {
|
||||
id,
|
||||
authorId: 'user-1',
|
||||
authorName: 'Alice',
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
replies: []
|
||||
};
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
documentId: 'doc-1',
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
canAdmin: false
|
||||
};
|
||||
|
||||
describe('CommentThread – empty state', () => {
|
||||
it('shows empty state hint when there are no comments', async () => {
|
||||
render(CommentThread, { ...baseProps, initialComments: [] });
|
||||
await expect
|
||||
.element(page.getByText('Noch keine Kommentare – starte die Diskussion!'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show empty state hint when comments exist', async () => {
|
||||
render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] });
|
||||
await expect
|
||||
.element(page.getByText('Noch keine Kommentare – starte die Diskussion!'))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CommentThread – onCountChange', () => {
|
||||
it('calls onCountChange with initial SSR count on mount', async () => {
|
||||
const onCountChange = vi.fn();
|
||||
render(CommentThread, {
|
||||
...baseProps,
|
||||
initialComments: [makeComment('c-1'), makeComment('c-2')],
|
||||
onCountChange
|
||||
});
|
||||
expect(onCountChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('calls onCountChange with 0 when no initial comments', async () => {
|
||||
const onCountChange = vi.fn();
|
||||
render(CommentThread, { ...baseProps, initialComments: [], onCountChange });
|
||||
expect(onCountChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('counts replies in the total', async () => {
|
||||
const onCountChange = vi.fn();
|
||||
const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] };
|
||||
render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange });
|
||||
expect(onCountChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -4,27 +4,7 @@ import PanelMetadata from './PanelMetadata.svelte';
|
||||
import PanelTranscription from './PanelTranscription.svelte';
|
||||
import PanelDiscussion from './PanelDiscussion.svelte';
|
||||
import PanelHistory from './PanelHistory.svelte';
|
||||
|
||||
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
|
||||
type CommentReply = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: CommentReply[];
|
||||
};
|
||||
import type { Comment, DocumentPanelTab } from '$lib/types';
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
@@ -47,7 +27,7 @@ type Props = {
|
||||
canAdmin: boolean;
|
||||
open: boolean;
|
||||
height: number;
|
||||
activeTab: Tab;
|
||||
activeTab: DocumentPanelTab;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -72,7 +52,7 @@ function fullHeight() {
|
||||
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
|
||||
}
|
||||
|
||||
function openTab(tab: Tab) {
|
||||
function openTab(tab: DocumentPanelTab) {
|
||||
activeTab = tab;
|
||||
if (!open) {
|
||||
open = true;
|
||||
@@ -110,7 +90,7 @@ function onDragEnd() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: () => string }[] = [
|
||||
const tabs: { id: DocumentPanelTab; label: () => string }[] = [
|
||||
{ id: 'metadata', label: m.doc_panel_tab_metadata },
|
||||
{ id: 'transcription', label: m.doc_panel_tab_transcription },
|
||||
{ id: 'discussion', label: m.doc_panel_tab_discussion },
|
||||
@@ -118,6 +98,12 @@ const tabs: { id: Tab; label: () => string }[] = [
|
||||
];
|
||||
|
||||
const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
|
||||
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
|
||||
|
||||
function handleCountChange(count: number) {
|
||||
discussionCount = count;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -151,6 +137,13 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
aria-pressed={activeTab === tab.id && open}
|
||||
>
|
||||
{tab.label()}
|
||||
{#if tab.id === 'discussion'}
|
||||
<span
|
||||
data-testid="discussion-count-badge"
|
||||
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
|
||||
>{discussionCount}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -185,6 +178,7 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
onCountChange={handleCountChange}
|
||||
/>
|
||||
{:else if activeTab === 'history'}
|
||||
<PanelHistory documentId={doc.id} />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentBottomPanel from './DocumentBottomPanel.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeComment(id: string): Comment {
|
||||
return {
|
||||
id,
|
||||
authorId: 'user-1',
|
||||
authorName: 'Alice',
|
||||
content: 'Hello',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
replies: []
|
||||
};
|
||||
}
|
||||
|
||||
const doc = { id: 'doc-1', title: 'Test' };
|
||||
|
||||
const baseProps = {
|
||||
doc,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
canAdmin: false,
|
||||
height: 300,
|
||||
activeTab: 'discussion' as const
|
||||
};
|
||||
|
||||
describe('DocumentBottomPanel – discussion badge', () => {
|
||||
it('always shows a badge on the Discussion tab', async () => {
|
||||
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('shows the correct count when comments exist', async () => {
|
||||
render(DocumentBottomPanel, {
|
||||
...baseProps,
|
||||
comments: [makeComment('c-1'), makeComment('c-2')],
|
||||
open: true
|
||||
});
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2');
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,6 @@
|
||||
<script lang="ts">
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
|
||||
type CommentReply = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: CommentReply[];
|
||||
};
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
@@ -26,9 +8,11 @@ type Props = {
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onCountChange?: (count: number) => void;
|
||||
};
|
||||
|
||||
let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props();
|
||||
let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
@@ -38,5 +22,6 @@ let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
import type { Annotation } from '$lib/types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
@@ -43,19 +44,6 @@ let textLayerInstance: { cancel: () => void } | null = null;
|
||||
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||
let pdfjsReady = $state(false);
|
||||
|
||||
type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
fileHash?: string | null;
|
||||
};
|
||||
|
||||
let annotations = $state<Annotation[]>([]);
|
||||
let annotateColor = $state('#ffff00');
|
||||
let commentCounts = new SvelteMap<string, number>();
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
suggestedName?: string;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
@@ -18,12 +19,20 @@ let {
|
||||
label,
|
||||
value = $bindable(''),
|
||||
initialName = '',
|
||||
suggestedName = '',
|
||||
restrictToCorrespondentsOf,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state(initialName);
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedName;
|
||||
if (suggested && !untrack(() => value)) {
|
||||
searchTerm = suggested;
|
||||
}
|
||||
});
|
||||
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
@@ -7,14 +8,26 @@ let {
|
||||
initialTitle = '',
|
||||
initialDocumentLocation = '',
|
||||
initialSummary = '',
|
||||
titleRequired = false
|
||||
titleRequired = false,
|
||||
suggestedTitle = ''
|
||||
}: {
|
||||
tags?: string[];
|
||||
initialTitle?: string;
|
||||
initialDocumentLocation?: string;
|
||||
initialSummary?: string;
|
||||
titleRequired?: boolean;
|
||||
suggestedTitle?: string;
|
||||
} = $props();
|
||||
|
||||
let titleValue = $state(untrack(() => initialTitle));
|
||||
let titleDirty = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedTitle;
|
||||
if (suggested && !untrack(() => titleDirty)) {
|
||||
titleValue = suggested;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -33,7 +46,11 @@ let {
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value={initialTitle}
|
||||
value={titleValue}
|
||||
oninput={(e) => {
|
||||
titleValue = (e.target as HTMLInputElement).value;
|
||||
titleDirty = true;
|
||||
}}
|
||||
required={titleRequired}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
/>
|
||||
|
||||
@@ -16,13 +16,17 @@ let {
|
||||
selectedReceivers = $bindable<Person[]>([]),
|
||||
initialDateIso = '',
|
||||
initialLocation = '',
|
||||
initialSenderName = ''
|
||||
initialSenderName = '',
|
||||
suggestedDateIso = '',
|
||||
suggestedSenderName = ''
|
||||
}: {
|
||||
senderId?: string;
|
||||
selectedReceivers?: Person[];
|
||||
initialDateIso?: string;
|
||||
initialLocation?: string;
|
||||
initialSenderName?: string;
|
||||
suggestedDateIso?: string;
|
||||
suggestedSenderName?: string;
|
||||
} = $props();
|
||||
|
||||
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
|
||||
@@ -37,6 +41,14 @@ function handleDateInput(e: Event) {
|
||||
dateIso = result.iso;
|
||||
dateDirty = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedDateIso;
|
||||
if (suggested && !untrack(() => dateDirty)) {
|
||||
dateDisplay = isoToGerman(suggested);
|
||||
dateIso = suggested;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -90,6 +102,7 @@ function handleDateInput(e: Event) {
|
||||
label={m.form_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
suggestedName={suggestedSenderName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -468,6 +468,54 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete-count": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getIncompleteCount"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getIncomplete"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete/next": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getNextIncomplete"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/search": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1819,6 +1867,77 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncompleteCount: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncomplete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getNextIncomplete: {
|
||||
parameters: {
|
||||
query: {
|
||||
excludeId: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"];
|
||||
};
|
||||
};
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
importStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
33
frontend/src/lib/types.ts
Normal file
33
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type CommentReply = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Comment = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: CommentReply[];
|
||||
};
|
||||
|
||||
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
|
||||
export type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
fileHash?: string | null;
|
||||
};
|
||||
101
frontend/src/lib/utils/filename.spec.ts
Normal file
101
frontend/src/lib/utils/filename.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFilename, stripExtension } from './filename';
|
||||
|
||||
describe('parseFilename', () => {
|
||||
describe('date-first patterns', () => {
|
||||
it('YYYY-MM-DD_Lastname_Firstname', () => {
|
||||
expect(parseFilename('1965-03-12_Mueller_Hans.pdf')).toEqual({
|
||||
dateIso: '1965-03-12',
|
||||
personName: 'Hans Mueller',
|
||||
suggestedTitle: 'Hans Mueller (12.03.1965)'
|
||||
});
|
||||
});
|
||||
|
||||
it('YYYYMMDD_Lastname_Firstname', () => {
|
||||
expect(parseFilename('19650312_Mueller_Hans.pdf')).toEqual({
|
||||
dateIso: '1965-03-12',
|
||||
personName: 'Hans Mueller',
|
||||
suggestedTitle: 'Hans Mueller (12.03.1965)'
|
||||
});
|
||||
});
|
||||
|
||||
it('YYYYMMDD_compound_lastname_Firstname', () => {
|
||||
expect(parseFilename('18881025_de_Gruyter_Walter.pdf')).toEqual({
|
||||
dateIso: '1888-10-25',
|
||||
personName: 'Walter de Gruyter',
|
||||
suggestedTitle: 'Walter de Gruyter (25.10.1888)'
|
||||
});
|
||||
});
|
||||
|
||||
it('handles umlauts in names', () => {
|
||||
const result = parseFilename('2024-01-15_Müller_Jürgen.pdf');
|
||||
expect(result.personName).toBe('Jürgen Müller');
|
||||
});
|
||||
});
|
||||
|
||||
describe('date-last patterns', () => {
|
||||
it('Lastname_Firstname_YYYY-MM-DD', () => {
|
||||
expect(parseFilename('Mueller_Hans_1965-03-12.pdf')).toEqual({
|
||||
dateIso: '1965-03-12',
|
||||
personName: 'Hans Mueller',
|
||||
suggestedTitle: 'Hans Mueller (12.03.1965)'
|
||||
});
|
||||
});
|
||||
|
||||
it('Lastname_Firstname_YYYYMMDD', () => {
|
||||
expect(parseFilename('Mueller_Hans_19650312.pdf')).toEqual({
|
||||
dateIso: '1965-03-12',
|
||||
personName: 'Hans Mueller',
|
||||
suggestedTitle: 'Hans Mueller (12.03.1965)'
|
||||
});
|
||||
});
|
||||
|
||||
it('compound_lastname_Firstname_YYYYMMDD', () => {
|
||||
expect(parseFilename('de_Gruyter_Walter_18881025.pdf')).toEqual({
|
||||
dateIso: '1888-10-25',
|
||||
personName: 'Walter de Gruyter',
|
||||
suggestedTitle: 'Walter de Gruyter (25.10.1888)'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-matching filenames', () => {
|
||||
it('returns empty for date-only filename', () => {
|
||||
expect(parseFilename('1965-03-12.pdf')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for two segments with no date', () => {
|
||||
expect(parseFilename('Mueller_Hans.pdf')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for unstructured filename', () => {
|
||||
expect(parseFilename('scan_001.pdf')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for three name segments with no date', () => {
|
||||
expect(parseFilename('Mueller_Hans_Juergen.pdf')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for filename without extension', () => {
|
||||
expect(parseFilename('1965-03-12_Mueller_Hans')).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects implausible date (month 13)', () => {
|
||||
expect(parseFilename('19651345_Mueller_Hans.pdf')).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripExtension', () => {
|
||||
it('removes the extension', () => {
|
||||
expect(stripExtension('document.pdf')).toBe('document');
|
||||
});
|
||||
|
||||
it('removes only the last extension', () => {
|
||||
expect(stripExtension('archive.tar.gz')).toBe('archive.tar');
|
||||
});
|
||||
|
||||
it('leaves names without extension unchanged', () => {
|
||||
expect(stripExtension('nodotfile')).toBe('nodotfile');
|
||||
});
|
||||
});
|
||||
83
frontend/src/lib/utils/filename.ts
Normal file
83
frontend/src/lib/utils/filename.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { isoToGerman } from './date';
|
||||
|
||||
export interface FilenameParseResult {
|
||||
/** ISO format: YYYY-MM-DD */
|
||||
dateIso?: string;
|
||||
/** "Firstname Lastname" — order reversed from filename convention */
|
||||
personName?: string;
|
||||
/** Ready-to-use title, e.g. "Hans Mueller (12.03.1965)" */
|
||||
suggestedTitle?: string;
|
||||
}
|
||||
|
||||
// A date token is either YYYY-MM-DD or YYYYMMDD with a plausible month/day range.
|
||||
function tryParseDate(s: string): string | undefined {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
||||
const m = parseInt(s.slice(5, 7));
|
||||
const d = parseInt(s.slice(8, 10));
|
||||
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
|
||||
} else if (/^\d{8}$/.test(s)) {
|
||||
const m = parseInt(s.slice(4, 6));
|
||||
const d = parseInt(s.slice(6, 8));
|
||||
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
|
||||
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const NAME_PART = /^\p{L}+$/u;
|
||||
|
||||
/**
|
||||
* Parses a structured filename and extracts a date and person name.
|
||||
*
|
||||
* Supported conventions (date-first or date-last, compound last names supported):
|
||||
* YYYY-MM-DD_Lastname_Firstname.ext
|
||||
* YYYYMMDD_Lastname_Firstname.ext
|
||||
* YYYYMMDD_de_Gruyter_Walter.ext ← compound last name: lastName="de Gruyter"
|
||||
* Lastname_Firstname_YYYY-MM-DD.ext
|
||||
* Lastname_Firstname_YYYYMMDD.ext
|
||||
* de_Gruyter_Walter_YYYYMMDD.ext ← compound last name: lastName="de Gruyter"
|
||||
*
|
||||
* Algorithm: split on "_", identify the date token (first or last segment),
|
||||
* treat the outermost remaining segment as firstName, rest as lastName parts.
|
||||
* Returns {} for anything that doesn't match cleanly.
|
||||
*/
|
||||
export function parseFilename(filename: string): FilenameParseResult {
|
||||
const dot = filename.lastIndexOf('.');
|
||||
if (dot < 0) return {}; // no extension — not a real file
|
||||
const stem = filename.slice(0, dot);
|
||||
const parts = stem.split('_');
|
||||
|
||||
// Minimum: date + at least one lastName segment + firstName = 3 parts
|
||||
if (parts.length < 3) return {};
|
||||
|
||||
let dateIso: string;
|
||||
let nameParts: string[];
|
||||
|
||||
const dateFromFirst = tryParseDate(parts[0]);
|
||||
if (dateFromFirst) {
|
||||
dateIso = dateFromFirst;
|
||||
nameParts = parts.slice(1);
|
||||
} else {
|
||||
const dateFromLast = tryParseDate(parts[parts.length - 1]);
|
||||
if (!dateFromLast) return {};
|
||||
dateIso = dateFromLast;
|
||||
nameParts = parts.slice(0, -1);
|
||||
}
|
||||
|
||||
// Need at least lastName + firstName after removing the date
|
||||
if (nameParts.length < 2) return {};
|
||||
|
||||
// All name segments must be pure letters (covers umlauts via \p{L})
|
||||
if (!nameParts.every((p) => NAME_PART.test(p))) return {};
|
||||
|
||||
const firstName = nameParts[nameParts.length - 1];
|
||||
const lastName = nameParts.slice(0, -1).join(' ');
|
||||
const personName = `${firstName} ${lastName}`;
|
||||
const suggestedTitle = `${personName} (${isoToGerman(dateIso)})`;
|
||||
|
||||
return { dateIso, personName, suggestedTitle };
|
||||
}
|
||||
|
||||
export function stripExtension(filename: string): string {
|
||||
return filename.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export async function load({ url, fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
try {
|
||||
const [docsResult, personsResult] = await Promise.all([
|
||||
const [docsResult, personsResult, incompleteCountResult] = await Promise.all([
|
||||
api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
@@ -25,7 +25,8 @@ export async function load({ url, fetch }) {
|
||||
}
|
||||
}
|
||||
}),
|
||||
api.GET('/api/persons')
|
||||
api.GET('/api/persons'),
|
||||
api.GET('/api/documents/incomplete-count')
|
||||
]);
|
||||
|
||||
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
||||
@@ -39,8 +40,13 @@ export async function load({ url, fetch }) {
|
||||
const senderObj = allPersons.find((p) => p.id === senderId);
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
const incompleteCount = incompleteCountResult.response.ok
|
||||
? (incompleteCountResult.data?.count ?? 0)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
documents,
|
||||
incompleteCount,
|
||||
initialValues: {
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||
@@ -53,6 +59,7 @@ export async function load({ url, fetch }) {
|
||||
console.error('Error loading data:', e);
|
||||
return {
|
||||
documents: [],
|
||||
incompleteCount: 0,
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import SearchFilterBar from './SearchFilterBar.svelte';
|
||||
import DropZone from './DropZone.svelte';
|
||||
import DocumentList from './DocumentList.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -86,5 +87,34 @@ $effect(() => {
|
||||
<DropZone />
|
||||
{/if}
|
||||
|
||||
{#if data.incompleteCount > 0}
|
||||
<a
|
||||
href="/enrich"
|
||||
class="mb-6 flex items-center justify-between rounded-sm border border-accent/40 bg-accent-bg px-6 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Info/Block/Info-Block-Border-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6 opacity-60"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.enrich_needs_metadata_title()}
|
||||
</p>
|
||||
<p class="mt-0.5 font-serif text-sm text-ink-2">
|
||||
{m.enrich_needs_metadata_count({ count: data.incompleteCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="font-sans text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:text-ink-2"
|
||||
>
|
||||
{m.enrich_needs_metadata_cta()} →
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
||||
</main>
|
||||
|
||||
@@ -142,32 +142,17 @@ $effect(() => {
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="mb-4 flex cursor-pointer items-center justify-center gap-3 border border-dashed px-6 text-sm transition-all duration-200 {isDragging
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center gap-2 border border-dashed px-6 transition-all duration-200 {isDragging
|
||||
? 'border-primary bg-accent-bg py-10 text-primary'
|
||||
: windowDragging
|
||||
? 'border-primary/60 bg-accent-bg/50 py-10 text-primary/80'
|
||||
: 'border-ink/20 py-3 text-ink-3 hover:border-primary hover:text-primary'}"
|
||||
: 'border-ink/20 py-6 text-ink-3 hover:border-primary hover:text-primary'}"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => fileInput.click()}
|
||||
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
{#if isUploading}
|
||||
<div class="flex w-48 flex-col items-center gap-1">
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-ink/10">
|
||||
@@ -179,8 +164,17 @@ $effect(() => {
|
||||
<span class="font-sans text-xs text-ink-3">{uploadProgress}%</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="font-sans font-medium">{m.upload_drop_hint()}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Copy-Item-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-8 w-8 opacity-40"
|
||||
/>
|
||||
<div class="flex flex-col items-center gap-0.5 text-center">
|
||||
<span class="font-sans text-sm text-ink-2">{m.upload_drop_hint()}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
|
||||
<span class="font-sans text-xs text-ink-3 italic">{m.upload_filename_hint()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
||||
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
||||
|
||||
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
import type { DocumentPanelTab } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -72,7 +71,7 @@ const LS_KEY_TAB = 'doc-panel-tab';
|
||||
let panelOpen = $state(false);
|
||||
let panelHeight = $state(0); // set to full height on mount
|
||||
let navHeight = $state(0);
|
||||
let activeTab = $state<Tab>('metadata');
|
||||
let activeTab = $state<DocumentPanelTab>('metadata');
|
||||
let localStorageRestored = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
@@ -82,7 +81,7 @@ onMount(() => {
|
||||
const savedTab = localStorage.getItem(LS_KEY_TAB);
|
||||
|
||||
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
|
||||
activeTab = savedTab as Tab;
|
||||
activeTab = savedTab as DocumentPanelTab;
|
||||
}
|
||||
const topbar = document.querySelector('[data-topbar]');
|
||||
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
|
||||
|
||||
@@ -60,6 +60,53 @@ export const actions = {
|
||||
throw redirect(303, `/documents/${params.id}`);
|
||||
},
|
||||
|
||||
markForReview: async ({
|
||||
params,
|
||||
fetch
|
||||
}: {
|
||||
params: { id: string };
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) => {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
// Fetch current document to preserve all existing fields
|
||||
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id: params.id } } });
|
||||
if (!docResult.response.ok) {
|
||||
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||
return fail(docResult.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
const doc = docResult.data!;
|
||||
const formData = new FormData();
|
||||
if (doc.title) formData.set('title', doc.title);
|
||||
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
|
||||
if (doc.location) formData.set('location', doc.location);
|
||||
if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation);
|
||||
if (doc.transcription) formData.set('transcription', doc.transcription);
|
||||
if (doc.summary) formData.set('summary', doc.summary);
|
||||
if (doc.sender?.id) formData.set('senderId', doc.sender.id);
|
||||
if (doc.receivers?.length) {
|
||||
doc.receivers.forEach((r: { id: string }) => formData.append('receiverIds', r.id));
|
||||
}
|
||||
if (doc.tags?.length) {
|
||||
formData.set('tags', doc.tags.map((t: { name: string }) => t.name).join(','));
|
||||
}
|
||||
formData.set('metadataComplete', 'false');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
||||
}
|
||||
|
||||
throw redirect(303, `/documents/${params.id}`);
|
||||
},
|
||||
|
||||
delete: async ({ params, fetch }) => {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { docId }: { docId: string } = $props();
|
||||
|
||||
@@ -54,7 +55,7 @@ let confirmDelete = $state(false);
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: cancel + save -->
|
||||
<!-- Right: cancel + mark for review + save -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/documents/{docId}"
|
||||
@@ -62,6 +63,13 @@ let confirmDelete = $state(false);
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
form="mark-for-review-form"
|
||||
class="rounded-sm border border-gray-300 px-4 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
|
||||
>
|
||||
{m.btn_mark_for_review()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
||||
@@ -70,3 +78,5 @@ let confirmDelete = $state(false);
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="mark-for-review-form" method="POST" action="?/markForReview" use:enhance></form>
|
||||
|
||||
@@ -55,22 +55,41 @@ export async function load({
|
||||
};
|
||||
}
|
||||
|
||||
async function submitNewDocument(
|
||||
request: Request,
|
||||
fetch: typeof globalThis.fetch,
|
||||
metadataComplete: boolean
|
||||
) {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const formData = await request.formData();
|
||||
formData.set('metadataComplete', String(metadataComplete));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/documents`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
||||
}
|
||||
|
||||
const created = await res.json();
|
||||
throw redirect(303, `/documents/${created.id}`);
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const formData = await request.formData();
|
||||
save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
|
||||
return submitNewDocument(request, fetch, false);
|
||||
},
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/documents`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { error: getErrorMessage(backendError?.code) });
|
||||
}
|
||||
|
||||
const created = await res.json();
|
||||
throw redirect(303, `/documents/${created.id}`);
|
||||
saveReviewed: async ({
|
||||
request,
|
||||
fetch
|
||||
}: {
|
||||
request: Request;
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) => {
|
||||
return submitNewDocument(request, fetch, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ 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();
|
||||
|
||||
@@ -14,6 +15,8 @@ let senderId = $state(untrack(() => data.initialSenderId));
|
||||
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state(
|
||||
untrack(() => data.initialReceivers)
|
||||
);
|
||||
|
||||
let parsedSuggestion = $state<FilenameParseResult>({});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
@@ -50,10 +53,16 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
initialSenderName={data.initialSenderName}
|
||||
suggestedDateIso={parsedSuggestion.dateIso ?? ''}
|
||||
suggestedSenderName={parsedSuggestion.personName ?? ''}
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
titleRequired={true}
|
||||
suggestedTitle={parsedSuggestion.suggestedTitle ?? ''}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} titleRequired={true} />
|
||||
<TranscriptionSection />
|
||||
<FileSectionNew />
|
||||
<FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
|
||||
|
||||
<!-- Sticky Save Bar -->
|
||||
<div
|
||||
@@ -62,12 +71,26 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $
|
||||
<a href="/" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
|
||||
{m.btn_cancel()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
name="metadataComplete"
|
||||
value="false"
|
||||
formaction="?/save"
|
||||
class="rounded-sm border border-gray-300 px-5 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="metadataComplete"
|
||||
value="true"
|
||||
formaction="?/saveReviewed"
|
||||
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||
>
|
||||
{m.btn_save_and_mark_reviewed()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { parseFilename, type FilenameParseResult } from '$lib/utils/filename';
|
||||
|
||||
let {
|
||||
onfileParsed
|
||||
}: {
|
||||
onfileParsed?: (result: FilenameParseResult) => void;
|
||||
} = $props();
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) onfileParsed?.(parseFilename(file.name));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -15,6 +27,7 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
id="file-upload"
|
||||
type="file"
|
||||
name="file"
|
||||
onchange={handleFileChange}
|
||||
class="block w-full cursor-pointer text-sm
|
||||
text-ink-2 file:mr-4 file:rounded
|
||||
file:border-0 file:bg-muted
|
||||
|
||||
23
frontend/src/routes/enrich/+page.server.ts
Normal file
23
frontend/src/routes/enrich/+page.server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
export async function load({
|
||||
fetch,
|
||||
locals
|
||||
}: {
|
||||
fetch: typeof globalThis.fetch;
|
||||
locals: App.Locals;
|
||||
}) {
|
||||
const canWrite =
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
if (!canWrite) throw redirect(303, '/');
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/documents/incomplete');
|
||||
|
||||
const documents = result.response.ok ? (result.data ?? []) : [];
|
||||
|
||||
return { documents };
|
||||
}
|
||||
106
frontend/src/routes/enrich/+page.svelte
Normal file
106
frontend/src/routes/enrich/+page.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const documents = $derived(data.documents);
|
||||
const count = $derived(documents.length);
|
||||
|
||||
function formatUploadDate(createdAt: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(new Date(createdAt));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||
<!-- Back Link -->
|
||||
<a
|
||||
href="/"
|
||||
class="group mb-4 inline-flex items-center font-sans text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
/>
|
||||
{m.enrich_list_back()}
|
||||
</a>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="border-brand-sand mb-8 flex items-center justify-between border-b pb-6">
|
||||
<div>
|
||||
<h1 class="font-serif text-3xl font-medium text-brand-navy">
|
||||
{m.enrich_list_heading()}
|
||||
</h1>
|
||||
{#if count > 0}
|
||||
<p class="mt-2 font-sans text-sm text-brand-navy/60">
|
||||
{count}
|
||||
{m.enrich_list_count()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if count > 0}
|
||||
<a
|
||||
href="/enrich/{documents[0].id}"
|
||||
class="bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||
>
|
||||
{m.enrich_list_start()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
{#if count === 0}
|
||||
<div
|
||||
class="border-brand-sand flex flex-col items-center justify-center rounded-sm border border-dashed bg-white py-20 text-center"
|
||||
>
|
||||
<div class="bg-brand-sand/60 mb-4 flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Check/Check-Circle-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-7 w-7 opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-serif text-lg font-medium text-brand-navy">
|
||||
{m.enrich_list_empty_heading()}
|
||||
</p>
|
||||
<p class="mt-2 max-w-xs font-sans text-sm text-brand-navy/60">
|
||||
{m.enrich_list_empty_body()}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Document Rows -->
|
||||
<div class="border-brand-sand border bg-white shadow-sm">
|
||||
<ul class="divide-brand-sand divide-y">
|
||||
{#each documents as doc (doc.id)}
|
||||
<li class="group hover:bg-brand-sand/30 transition-colors duration-200">
|
||||
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="font-serif text-lg font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</p>
|
||||
<p class="mt-1 font-sans text-xs text-brand-navy/50">
|
||||
{formatUploadDate(doc.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
109
frontend/src/routes/enrich/[id]/+page.server.ts
Normal file
109
frontend/src/routes/enrich/[id]/+page.server.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage, parseBackendError } from '$lib/errors';
|
||||
|
||||
export async function load({
|
||||
params,
|
||||
fetch,
|
||||
locals
|
||||
}: {
|
||||
params: { id: string };
|
||||
fetch: typeof globalThis.fetch;
|
||||
locals: App.Locals;
|
||||
}) {
|
||||
const canWrite =
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
if (!canWrite) throw redirect(303, '/');
|
||||
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const [docResult, countResult] = await Promise.all([
|
||||
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||
api.GET('/api/documents/incomplete-count')
|
||||
]);
|
||||
|
||||
if (!docResult.response.ok) {
|
||||
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||
throw error(docResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
const incompleteCount = countResult.response.ok ? (countResult.data?.count ?? 0) : 0;
|
||||
|
||||
return {
|
||||
document: docResult.data!,
|
||||
incompleteCount
|
||||
};
|
||||
}
|
||||
|
||||
async function redirectToNext(id: string, fetch: typeof globalThis.fetch): Promise<never> {
|
||||
const api = createApiClient(fetch);
|
||||
const nextResult = await api.GET('/api/documents/incomplete/next', {
|
||||
params: { query: { excludeId: id } }
|
||||
});
|
||||
|
||||
if (nextResult.response.ok && nextResult.data) {
|
||||
throw redirect(303, `/enrich/${nextResult.data.id}`);
|
||||
}
|
||||
throw redirect(303, '/enrich/done');
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
skip: async ({ params, fetch }: { params: { id: string }; fetch: typeof globalThis.fetch }) => {
|
||||
await redirectToNext(params.id, fetch);
|
||||
},
|
||||
|
||||
save: async ({
|
||||
params,
|
||||
request,
|
||||
fetch
|
||||
}: {
|
||||
params: { id: string };
|
||||
request: Request;
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) => {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const formData = await request.formData();
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return { error: getErrorMessage(backendError?.code) };
|
||||
}
|
||||
|
||||
await redirectToNext(params.id, fetch);
|
||||
},
|
||||
|
||||
saveAndReview: async ({
|
||||
params,
|
||||
request,
|
||||
fetch
|
||||
}: {
|
||||
params: { id: string };
|
||||
request: Request;
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) => {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const formData = await request.formData();
|
||||
formData.set('metadataComplete', 'true');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return { error: getErrorMessage(backendError?.code) };
|
||||
}
|
||||
|
||||
await redirectToNext(params.id, fetch);
|
||||
}
|
||||
};
|
||||
167
frontend/src/routes/enrich/[id]/+page.svelte
Normal file
167
frontend/src/routes/enrich/[id]/+page.svelte
Normal file
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
||||
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const doc = $derived(data.document);
|
||||
|
||||
// File preview state
|
||||
let fileUrl = $state('');
|
||||
let fileError = $state('');
|
||||
let isLoading = $state(false);
|
||||
|
||||
let navHeight = $state(0);
|
||||
onMount(() => {
|
||||
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||
});
|
||||
|
||||
// Dummy bindable state required by DocumentViewer
|
||||
let annotateMode = $state(false);
|
||||
let activeAnnotationId = $state<string | null>(null);
|
||||
let activeAnnotationPage = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (doc?.id && doc?.filePath) {
|
||||
loadFile(doc.id);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFile(id: string) {
|
||||
isLoading = true;
|
||||
fileError = '';
|
||||
fileUrl = '';
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${id}/file`);
|
||||
if (!response.ok) throw new Error('Fehler');
|
||||
const blob = await response.blob();
|
||||
fileUrl = URL.createObjectURL(blob);
|
||||
} catch {
|
||||
fileError = m.doc_file_error_preview();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Form state
|
||||
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
|
||||
let senderId = $state(untrack(() => doc.sender?.id ?? ''));
|
||||
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{doc.title || doc.originalFilename || 'Dokument'} — Anreicherung</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: {navHeight}px">
|
||||
<!-- Top bar -->
|
||||
<div class="flex items-center justify-between border-b border-line bg-surface px-6 py-3">
|
||||
<a
|
||||
href="/enrich"
|
||||
class="group inline-flex items-center font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
/>
|
||||
{m.enrich_back_to_list()}
|
||||
</a>
|
||||
|
||||
<p class="max-w-sm truncate text-center font-serif text-sm font-medium text-ink">
|
||||
{doc.title || doc.originalFilename}
|
||||
</p>
|
||||
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{m.enrich_progress({ count: data.incompleteCount })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left: PDF preview (60%) -->
|
||||
<div class="relative flex-[6] overflow-hidden border-r border-line">
|
||||
<DocumentViewer
|
||||
doc={doc}
|
||||
fileUrl={fileUrl}
|
||||
isLoading={isLoading}
|
||||
error={fileError}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
onAnnotationClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: form (40%) -->
|
||||
<div class="flex flex-[4] flex-col overflow-hidden">
|
||||
{#if form?.error}
|
||||
<div class="border-b border-red-200 bg-red-50 px-6 py-3 text-sm text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
id="save-form"
|
||||
method="POST"
|
||||
action="?/save"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance
|
||||
class="flex-1 space-y-5 overflow-y-auto p-6"
|
||||
>
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
initialDateIso={doc.documentDate ?? ''}
|
||||
initialLocation={doc.location ?? ''}
|
||||
initialSenderName={doc.sender
|
||||
? `${doc.sender.firstName} ${doc.sender.lastName}`
|
||||
: ''}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} initialTitle={doc.title ?? ''} titleRequired={true} />
|
||||
</form>
|
||||
|
||||
<!-- Skip form (outside main form to avoid nesting) -->
|
||||
<form id="skip-form" method="POST" action="?/skip" use:enhance></form>
|
||||
|
||||
<!-- Action bar -->
|
||||
<div class="flex items-center justify-between gap-3 border-t border-line bg-surface p-4">
|
||||
<!-- Skip button linked to skip-form -->
|
||||
<button
|
||||
type="submit"
|
||||
form="skip-form"
|
||||
class="font-sans text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||
>
|
||||
{m.enrich_skip()}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Save -->
|
||||
<button
|
||||
type="submit"
|
||||
form="save-form"
|
||||
formaction="?/save"
|
||||
class="rounded-sm border border-gray-300 px-5 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
|
||||
<!-- Save & mark as reviewed -->
|
||||
<button
|
||||
type="submit"
|
||||
form="save-form"
|
||||
formaction="?/saveAndReview"
|
||||
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||
>
|
||||
{m.btn_save_and_mark_reviewed()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
40
frontend/src/routes/enrich/done/+page.svelte
Normal file
40
frontend/src/routes/enrich/done/+page.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||
<div
|
||||
class="border-brand-sand flex flex-col items-center justify-center rounded-sm border bg-white py-20 text-center shadow-sm"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Large-32px/SVG/Action/Check/Check-Double-LG.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mb-6 h-16 w-16"
|
||||
/>
|
||||
|
||||
<h1 class="font-serif text-2xl font-medium text-brand-navy">
|
||||
{m.enrich_done_heading()}
|
||||
</h1>
|
||||
|
||||
<p class="mt-2 max-w-xs font-sans text-sm text-gray-500">
|
||||
{m.enrich_done_body()}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-col items-center gap-4">
|
||||
<a
|
||||
href="/"
|
||||
class="bg-brand-navy px-6 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||
>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/enrich"
|
||||
class="font-sans text-xs text-gray-400 underline-offset-4 transition-colors hover:text-brand-navy hover:underline"
|
||||
>
|
||||
{m.enrich_back_to_list()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,6 +23,7 @@ const emptyData = {
|
||||
canAnnotate: false,
|
||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||
documents: [],
|
||||
incompleteCount: 0,
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
error: null
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user