feat: dedicated /documents search & browse page #282
@@ -38,7 +38,15 @@ public class AuditLogQueryService {
|
||||
|
||||
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
List<ContributorRow> rows = queryRepository.findContributorsPerDocument(documentIds);
|
||||
return toContributorMap(queryRepository.findContributorsPerDocument(documentIds));
|
||||
}
|
||||
|
||||
public Map<UUID, List<ActivityActorDTO>> findRecentContributorsPerDocument(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
||||
}
|
||||
|
||||
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||
for (ContributorRow row : rows) {
|
||||
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())
|
||||
|
||||
@@ -3,8 +3,11 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
@@ -18,7 +21,9 @@ import org.raddatz.familienarchiv.model.ScriptType;
|
||||
import org.raddatz.familienarchiv.model.TrainingLabel;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.CompletionStatsRow;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
@@ -59,6 +64,8 @@ public class DocumentService {
|
||||
private final DocumentVersionService documentVersionService;
|
||||
private final AnnotationService annotationService;
|
||||
private final AuditService auditService;
|
||||
private final TranscriptionBlockRepository transcriptionBlockRepository;
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
|
||||
public record StoreResult(Document document, boolean isNew) {}
|
||||
|
||||
@@ -344,7 +351,7 @@ public class DocumentService {
|
||||
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
}
|
||||
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
@@ -363,13 +370,11 @@ public class DocumentService {
|
||||
// generates an INNER JOIN that silently drops documents with null sender/receivers.
|
||||
if (sort == DocumentSort.RECEIVER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
List<Document> sorted = sortByFirstReceiver(results, dir);
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||
return buildResult(sortByFirstReceiver(results, dir), text);
|
||||
}
|
||||
if (sort == DocumentSort.SENDER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
List<Document> sorted = sortBySender(results, dir);
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||
return buildResult(sortBySender(results, dir), text);
|
||||
}
|
||||
|
||||
// RELEVANCE: default when text present and no explicit sort given
|
||||
@@ -382,12 +387,39 @@ public class DocumentService {
|
||||
.sorted(Comparator.comparingInt(
|
||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||
return buildResult(sorted, text);
|
||||
}
|
||||
|
||||
Sort springSort = resolveSort(sort, dir);
|
||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text));
|
||||
return buildResult(results, text);
|
||||
}
|
||||
|
||||
private DocumentSearchResult buildResult(List<Document> documents, String text) {
|
||||
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||
|
||||
List<UUID> docIds = colorResolved.stream().map(Document::getId).toList();
|
||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
||||
|
||||
List<DocumentSearchItem> items = colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||
doc,
|
||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
||||
)).toList();
|
||||
|
||||
return DocumentSearchResult.of(items);
|
||||
}
|
||||
|
||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||
if (docIds.isEmpty()) return Map.of();
|
||||
Map<UUID, Integer> result = new HashMap<>();
|
||||
for (CompletionStatsRow row : transcriptionBlockRepository.findCompletionStatsForDocuments(docIds)) {
|
||||
result.put(row.getDocumentId(), row.getCompletionPercentage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Sort resolveSort(DocumentSort sort, String dir) {
|
||||
|
||||
@@ -25,10 +25,12 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -115,12 +117,12 @@ class DocumentControllerTest {
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(0))
|
||||
.andExpect(jsonPath("$.documents").isArray());
|
||||
.andExpect(jsonPath("$.items").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseBodyContainsMatchDataKey() throws Exception {
|
||||
void search_responseBodyItemsContainMatchData() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder()
|
||||
.id(docId)
|
||||
@@ -128,15 +130,15 @@ class DocumentControllerTest {
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData(
|
||||
var matchData = new SearchMatchData(
|
||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.matchData").isMap())
|
||||
.andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet")
|
||||
.andExpect(jsonPath("$.items").isArray())
|
||||
.andExpect(jsonPath("$.items[0].matchData.transcriptionSnippet")
|
||||
.value("Er schrieb einen langen Brief"));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
@@ -30,6 +32,8 @@ class DocumentServiceSortTest {
|
||||
@Mock TagService tagService;
|
||||
@Mock DocumentVersionService documentVersionService;
|
||||
@Mock AnnotationService annotationService;
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockRepository transcriptionBlockRepository;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
|
||||
@@ -56,8 +60,8 @@ class DocumentServiceSortTest {
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null);
|
||||
|
||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||
assertThat(result.documents()).hasSize(2);
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
|
||||
}
|
||||
|
||||
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
|
||||
@@ -78,7 +82,7 @@ class DocumentServiceSortTest {
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
|
||||
// Expect: rank order restored (id1 first)
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,6 +100,6 @@ class DocumentServiceSortTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
@@ -20,6 +22,7 @@ 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.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -51,6 +54,8 @@ class DocumentServiceTest {
|
||||
@Mock DocumentVersionService documentVersionService;
|
||||
@Mock AnnotationService annotationService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockRepository transcriptionBlockRepository;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||
@@ -1298,8 +1303,8 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
|
||||
assertThat(result.documents()).hasSize(2);
|
||||
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||
}
|
||||
|
||||
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
||||
@@ -1318,7 +1323,7 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null);
|
||||
|
||||
assertThat(result.documents()).extracting(Document::getTitle)
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
}
|
||||
|
||||
@@ -1341,7 +1346,7 @@ class DocumentServiceTest {
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
|
||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||
assertThat(result.documents()).extracting(Document::getTitle)
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
.containsExactly("smith doc", "Null lastname doc");
|
||||
}
|
||||
|
||||
@@ -1362,8 +1367,8 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
|
||||
assertThat(result.matchData()).containsKey(docId);
|
||||
SearchMatchData md = result.matchData().get(docId);
|
||||
assertThat(result.items()).hasSize(1);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.titleOffsets()).hasSize(1);
|
||||
assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
|
||||
}
|
||||
@@ -1376,7 +1381,7 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result.matchData()).isEmpty();
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1395,7 +1400,7 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
|
||||
SearchMatchData md = result.matchData().get(docId);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||
assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user