feat(search): show search result snippets with match highlighting (#219) #242

Merged
marcel merged 20 commits from feat/issue-219-search-snippets into main 2026-04-16 09:10:11 +02:00
27 changed files with 1550 additions and 101 deletions

View File

@@ -208,8 +208,7 @@ public class DocumentController {
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
}
List<Document> results = documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir);
return ResponseEntity.ok(DocumentSearchResult.of(results));
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir));
}
// --- TRAINING LABELS ---

View File

@@ -1,16 +1,35 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.model.Document;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record DocumentSearchResult(List<Document> documents, long total) {
public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Document> documents,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long total,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Map<UUID, SearchMatchData> matchData
) {
/**
* Creates a result where total equals the list size.
* Creates a fully-enriched result from documents and their match overlay data.
* Absent map entries (e.g. document deleted between FTS and enrichment) are safe —
* the frontend treats a missing entry as "no match data".
*/
public static DocumentSearchResult withMatchData(List<Document> documents, Map<UUID, SearchMatchData> matchData) {
return new DocumentSearchResult(documents, documents.size(), matchData);
}
/**
* Creates a result without match data — used for filter-only searches (no text query).
* No pagination yet — the full matched set is always returned.
* When pagination is added, total must come from a DB COUNT query, not list.size().
*/
public static DocumentSearchResult of(List<Document> documents) {
return new DocumentSearchResult(documents, documents.size());
return withMatchData(documents, Map.of());
}
}

View File

@@ -0,0 +1,14 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Character-level offset of a highlighted term within a text field.
* Offsets are Java {@code String} character positions (UTF-16 code units),
* which are identical to JavaScript string positions — consistent end-to-end
* for all German BMP characters (ä, ö, ü, ß, etc.).
*/
public record MatchOffset(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int start,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int length
) {}

View File

@@ -0,0 +1,67 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.UUID;
/**
* Match signals for a single document in a full-text search result.
* All fields are non-null except {@code transcriptionSnippet} and {@code summarySnippet},
* which are null when the respective field did not match the query.
*/
public record SearchMatchData(
/**
* Best-ranked matching transcription line, or null if no block matched.
*/
String transcriptionSnippet,
/**
* Character offsets of highlighted terms within the document title.
* Empty when the title did not contribute to the match.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<MatchOffset> titleOffsets,
/**
* True when the sender's name matched the query.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
boolean senderMatched,
/**
* IDs of receiver persons whose names matched the query.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<UUID> matchedReceiverIds,
/**
* IDs of tags whose names matched the query.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<UUID> matchedTagIds,
/**
* Character offsets of highlighted terms within the transcription snippet.
* Empty when no transcription block matched or the snippet has no highlights.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<MatchOffset> snippetOffsets,
/**
* Highlighted summary excerpt, or null if the summary did not match the query.
*/
String summarySnippet,
/**
* Character offsets of highlighted terms within the summary snippet.
* Empty when the summary did not match or has no highlights.
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<MatchOffset> summaryOffsets
) {
/** Canonical "no match data" value for a single document. */
public static SearchMatchData empty() {
return new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
}
}

View File

@@ -83,10 +83,88 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@Query(nativeQuery = true, value = """
SELECT d.id FROM documents d
WHERE d.search_vector @@ websearch_to_tsquery('german', :query)
ORDER BY ts_rank(d.search_vector, websearch_to_tsquery('german', :query)) DESC,
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',
'g'))
END AS pq
) q
WHERE d.search_vector @@ q.pq
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
d.meta_date DESC NULLS LAST
""")
List<UUID> findRankedIdsByFts(@Param("query") String query);
/**
* Returns match-enrichment data for a set of documents identified by their IDs.
* Each row contains (in column order):
* <ol>
* <li>UUID — document id</li>
* <li>String — title headline with \x01/\x02 delimiters around matched terms</li>
* <li>String — best-ranked transcription snippet with \x01/\x02 delimiters, or null</li>
* <li>Boolean — whether the sender's name matched the query</li>
* <li>String — comma-separated matched receiver UUIDs, or null</li>
* <li>String — comma-separated matched tag UUIDs, or null</li>
* <li>String — summary snippet with \x01/\x02 delimiters, or null if summary didn't match</li>
* </ol>
* Short-circuit before calling this method when {@code ids} is empty or {@code query} is blank.
*/
@Query(nativeQuery = true, value = """
SELECT
d.id,
ts_headline('german', d.title, q.pq,
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',HighlightAll=true')
AS title_headline,
CASE WHEN best_block.text IS NOT NULL THEN
ts_headline('german', best_block.text, q.pq,
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',MaxWords=50,MinWords=20')
END AS transcription_snippet,
(s.id IS NOT NULL AND
to_tsvector('german', COALESCE(s.first_name, '') || ' ' || COALESCE(s.last_name, ''))
@@ q.pq)
AS sender_matched,
(SELECT string_agg(r.id::text, ',')
FROM document_receivers dr
JOIN persons r ON r.id = dr.person_id
WHERE dr.document_id = d.id
AND to_tsvector('german', COALESCE(r.first_name, '') || ' ' || r.last_name)
@@ q.pq
) AS matched_receiver_ids,
(SELECT string_agg(t.id::text, ',')
FROM document_tags dt
JOIN tag t ON t.id = dt.tag_id
WHERE dt.document_id = d.id
AND to_tsvector('german', t.name) @@ q.pq
) AS matched_tag_ids,
CASE WHEN d.summary IS NOT NULL AND d.summary <> ''
AND to_tsvector('german', d.summary) @@ q.pq
THEN ts_headline('german', d.summary, q.pq,
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',MaxWords=50,MinWords=20')
END AS summary_snippet
FROM documents d
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',
'g'))
END AS pq
) q
LEFT JOIN persons s ON s.id = d.sender_id
LEFT JOIN LATERAL (
SELECT tb.text
FROM transcription_blocks tb
WHERE tb.document_id = d.id
AND to_tsvector('german', tb.text) @@ q.pq
ORDER BY ts_rank(to_tsvector('german', tb.text), q.pq) DESC
LIMIT 1
) best_block ON true
WHERE d.id IN :ids
""")
List<Object[]> findEnrichmentData(@Param("ids") Collection<UUID> ids, @Param("query") String query);
}

View File

@@ -3,10 +3,13 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.dto.MatchOffset;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.ScriptType;
import org.raddatz.familienarchiv.model.TrainingLabel;
@@ -290,13 +293,13 @@ public class DocumentService {
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) return List.of();
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
}
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
@@ -312,11 +315,13 @@ 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);
return sortByFirstReceiver(results, dir);
List<Document> sorted = sortByFirstReceiver(results, dir);
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
}
if (sort == DocumentSort.SENDER) {
List<Document> results = documentRepository.findAll(spec);
return sortBySender(results, dir);
List<Document> sorted = sortBySender(results, dir);
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
}
// RELEVANCE: default when text present and no explicit sort given
@@ -325,14 +330,16 @@ public class DocumentService {
List<Document> results = documentRepository.findAll(spec);
Map<UUID, Integer> rankMap = new HashMap<>();
for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
return results.stream()
List<Document> sorted = results.stream()
.sorted(Comparator.comparingInt(
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
.toList();
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
}
Sort springSort = resolveSort(sort, dir);
return documentRepository.findAll(spec, springSort);
List<Document> results = documentRepository.findAll(spec, springSort);
return DocumentSearchResult.withMatchData(results, enrichWithMatchData(results, text));
}
private Sort resolveSort(DocumentSort sort, String dir) {
@@ -584,6 +591,93 @@ public class DocumentService {
return null;
}
/**
* Calls {@code findEnrichmentData} and converts the raw Object[] rows into a
* {@link SearchMatchData} per document. Short-circuits when the list is empty or
* the query is blank (no text search active).
*/
private Map<UUID, SearchMatchData> enrichWithMatchData(List<Document> docs, String query) {
if (docs.isEmpty() || !StringUtils.hasText(query)) return Map.of();
List<UUID> ids = docs.stream().map(Document::getId).toList();
Map<UUID, SearchMatchData> result = new HashMap<>();
for (Object[] row : documentRepository.findEnrichmentData(ids, query)) {
UUID docId = (UUID) row[0];
String titleHeadline = (String) row[1];
String snippetHeadline = (String) row[2];
Boolean senderMatched = (Boolean) row[3];
String receiverIdsStr = (String) row[4];
String tagIdsStr = (String) row[5];
String summaryHeadline = (String) row[6];
ParsedHighlight snippet = parseHighlight(snippetHeadline);
ParsedHighlight summary = parseHighlight(summaryHeadline);
result.put(docId, new SearchMatchData(
snippet != null ? snippet.cleanText() : null,
parseTitleOffsets(titleHeadline),
senderMatched != null && senderMatched,
parseUUIDs(receiverIdsStr),
parseUUIDs(tagIdsStr),
snippet != null ? snippet.offsets() : List.of(),
summary != null ? summary.cleanText() : null,
summary != null ? summary.offsets() : List.of()
));
}
return result;
}
/** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}
/**
* Parses a {@code ts_headline} result that uses {@code chr(1)}/{@code chr(2)} as
* start/stop delimiters. Returns the clean text (delimiters stripped) together with
* the character offsets of each highlighted span. Returns {@code null} when
* {@code headline} is {@code null}.
*/
public static ParsedHighlight parseHighlight(String headline) {
if (headline == null) return null;
StringBuilder clean = new StringBuilder(headline.length());
List<MatchOffset> offsets = new ArrayList<>();
int i = 0;
int pos = 0; // position in the clean string (no delimiters)
while (i < headline.length()) {
char c = headline.charAt(i);
if (c == '\u0001') {
int start = pos;
i++;
while (i < headline.length() && headline.charAt(i) != '\u0002') {
clean.append(headline.charAt(i));
i++;
pos++;
}
offsets.add(new MatchOffset(start, pos - start));
i++; // skip \u0002
} else {
clean.append(c);
i++;
pos++;
}
}
return new ParsedHighlight(clean.toString(), offsets);
}
/**
* Extracts only the {@link MatchOffset} list from a title headline.
* The clean title text comes from the {@link Document} entity itself.
*/
private static List<MatchOffset> parseTitleOffsets(String headline) {
ParsedHighlight parsed = parseHighlight(headline);
return parsed != null ? parsed.offsets() : List.of();
}
private static List<UUID> parseUUIDs(String csv) {
if (csv == null || csv.isBlank()) return List.of();
return Arrays.stream(csv.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(UUID::fromString)
.toList();
}
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");

View File

@@ -0,0 +1,4 @@
-- Index on transcription_blocks.document_id to speed up the LATERAL join
-- used in DocumentService.findEnrichmentData (FTS match enrichment).
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_id
ON transcription_blocks (document_id);

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.model.Document;
@@ -24,6 +25,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -61,7 +63,7 @@ class DocumentControllerTest {
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(Collections.emptyList());
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk());
@@ -71,7 +73,7 @@ class DocumentControllerTest {
@WithMockUser
void search_withStatusParam_passesItToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any()))
.thenReturn(Collections.emptyList());
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk());
@@ -104,7 +106,7 @@ class DocumentControllerTest {
@WithMockUser
void search_responseContainsTotalCount() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(Collections.emptyList());
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
@@ -112,6 +114,28 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.documents").isArray());
}
@Test
@WithMockUser
void search_responseBodyContainsMatchDataKey() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder()
.id(docId)
.title("Brief an Anna")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.build();
var matchData = new org.raddatz.familienarchiv.dto.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()))
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.matchData").isMap())
.andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet")
.value("Er schrieb einen langen Brief"));
}
// ─── POST /api/documents ─────────────────────────────────────────────────
@Test

View File

@@ -0,0 +1,68 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DocumentSearchResultTest {
private Document doc(UUID id) {
return Document.builder()
.id(id)
.title("Test")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.build();
}
@Test
void withMatchData_total_equals_list_size() {
UUID id = UUID.randomUUID();
List<Document> docs = List.of(doc(id));
Map<UUID, SearchMatchData> matchData = Map.of(id, SearchMatchData.empty());
DocumentSearchResult result = DocumentSearchResult.withMatchData(docs, matchData);
assertThat(result.total()).isEqualTo(1L);
}
@Test
void withMatchData_exposes_match_data_map() {
UUID id = UUID.randomUUID();
SearchMatchData data = new SearchMatchData("snippet", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
DocumentSearchResult result = DocumentSearchResult.withMatchData(List.of(doc(id)), Map.of(id, data));
assertThat(result.matchData()).containsKey(id);
assertThat(result.matchData().get(id).transcriptionSnippet()).isEqualTo("snippet");
}
@Test
void of_factory_returns_empty_match_data() {
UUID id = UUID.randomUUID();
DocumentSearchResult result = DocumentSearchResult.of(List.of(doc(id)));
assertThat(result.matchData()).isEmpty();
assertThat(result.total()).isEqualTo(1L);
}
@Test
void documents_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("documents").getAnnotation(Schema.class);
assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
@Test
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
}

View File

@@ -0,0 +1,22 @@
package org.raddatz.familienarchiv.dto;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MatchOffsetTest {
@Test
void should_hold_start_and_length() {
MatchOffset offset = new MatchOffset(6, 5);
assertThat(offset.start()).isEqualTo(6);
assertThat(offset.length()).isEqualTo(5);
}
@Test
void should_implement_value_equality() {
assertThat(new MatchOffset(0, 3)).isEqualTo(new MatchOffset(0, 3));
assertThat(new MatchOffset(0, 3)).isNotEqualTo(new MatchOffset(0, 4));
}
}

View File

@@ -0,0 +1,69 @@
package org.raddatz.familienarchiv.dto;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class SearchMatchDataTest {
@Test
void transcription_snippet_is_nullable() {
SearchMatchData data = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
assertThat(data.transcriptionSnippet()).isNull();
}
@Test
void non_null_list_fields_are_empty_by_default_in_empty_factory() {
SearchMatchData data = SearchMatchData.empty();
assertThat(data.transcriptionSnippet()).isNull();
assertThat(data.titleOffsets()).isEmpty();
assertThat(data.matchedReceiverIds()).isEmpty();
assertThat(data.matchedTagIds()).isEmpty();
assertThat(data.senderMatched()).isFalse();
}
@Test
void holds_all_field_values() {
MatchOffset offset = new MatchOffset(0, 4);
SearchMatchData data = new SearchMatchData(
"schreibt dir aus dem Feld",
List.of(offset),
true,
List.of(),
List.of(),
List.of(),
null,
List.of()
);
assertThat(data.transcriptionSnippet()).isEqualTo("schreibt dir aus dem Feld");
assertThat(data.titleOffsets()).containsExactly(offset);
assertThat(data.senderMatched()).isTrue();
}
@Test
void snippet_offsets_are_empty_in_empty_factory() {
SearchMatchData data = SearchMatchData.empty();
assertThat(data.snippetOffsets()).isEmpty();
}
@Test
void snippet_offsets_carry_through_constructor() {
MatchOffset offset = new MatchOffset(5, 3);
SearchMatchData data = new SearchMatchData(
"Das ist ein furchtbares Bild",
List.of(),
false,
List.of(),
List.of(),
List.of(offset),
null,
List.of()
);
assertThat(data.snippetOffsets()).containsExactly(offset);
}
}

View File

@@ -79,6 +79,16 @@ class DocumentFtsTest {
assertThat(ids).hasSize(1);
}
@Test
void should_find_document_by_partial_word_prefix() {
documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("furchtb");
assertThat(ids).hasSize(1);
}
@Test
void should_not_find_document_when_term_absent() {
documentRepository.saveAndFlush(document("Familienfoto"));

View File

@@ -0,0 +1,307 @@
package org.raddatz.familienarchiv.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class DocumentSearchEnrichmentTest {
@Autowired DocumentRepository documentRepository;
@Autowired PersonRepository personRepository;
@Autowired TagRepository tagRepository;
@Autowired AnnotationRepository annotationRepository;
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired EntityManager em;
@BeforeEach
void setUp() {
blockRepository.deleteAll();
documentRepository.deleteAll();
personRepository.deleteAll();
tagRepository.deleteAll();
}
// ─── Lateral join: best transcription snippet ──────────────────────────────
@Test
void lateral_join_returns_highest_ranked_transcription_block() {
Document doc = documentRepository.saveAndFlush(document("Brief an Anna"));
UUID annotId = annotation(doc.getId());
// Three blocks — the one with three occurrences has highest rank
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Das Wetter war schön", 0));
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Brief Brief Brief", 1)); // highest rank for "Brief"
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Ein Brief liegt vor", 2)); // one occurrence
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
// row[2] is now a ts_headline result with sentinel chars — parse it for clean text
DocumentService.ParsedHighlight parsed = DocumentService.parseHighlight((String) rows.get(0)[2]);
assertThat(parsed).isNotNull();
assertThat(parsed.cleanText()).isEqualTo("Brief Brief Brief");
assertThat(parsed.offsets()).isNotEmpty(); // at least one "Brief" is highlighted
}
@Test
void document_with_no_transcription_blocks_has_null_snippet() {
Document doc = documentRepository.saveAndFlush(document("Foto ohne Text"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Foto");
assertThat(rows).hasSize(1);
Object snippet = rows.get(0)[2];
assertThat(snippet).isNull();
}
@Test
void document_with_non_matching_blocks_has_null_snippet() {
Document doc = documentRepository.saveAndFlush(document("Dok"));
UUID annotId = annotation(doc.getId());
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Kein Match hier", 0));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[2]).isNull();
}
// ─── Title headline: delimiter-based offset detection ─────────────────────
@Test
void title_headline_contains_delimiters_when_title_matches() {
Document doc = documentRepository.saveAndFlush(document("Brief an die Familie"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String headline = (String) rows.get(0)[1];
// chr(1) marks the start of the highlighted term
assertThat(headline).contains("\u0001");
assertThat(headline).contains("\u0002");
}
@Test
void title_headline_has_no_delimiters_when_title_does_not_match() {
Document doc = documentRepository.saveAndFlush(document("Familienfoto"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String headline = (String) rows.get(0)[1];
assertThat(headline).doesNotContain("\u0001");
assertThat(headline).doesNotContain("\u0002");
}
@Test
void title_headline_matches_stemmed_form() {
// "Brief" (singular, query) should match "Briefe" (plural, in title) via German FTS stemming.
// Both reduce to the stem "brief" under the Snowball German algorithm — verified by the
// existing should_find_document_by_stemmed_inflected_form test in DocumentFtsTest.
Document doc = documentRepository.saveAndFlush(document("Alte Briefe aus Berlin"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String headline = (String) rows.get(0)[1];
assertThat(headline).contains("\u0001");
}
// ─── Sender match ──────────────────────────────────────────────────────────
@Test
void sender_matched_is_true_when_sender_last_name_matches_query() {
Person sender = personRepository.saveAndFlush(
Person.builder().firstName("Walter").lastName("Raddatz").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Raddatz");
assertThat(rows).hasSize(1);
Boolean senderMatched = (Boolean) rows.get(0)[3];
assertThat(senderMatched).isTrue();
}
@Test
void sender_matched_is_false_when_sender_name_does_not_match() {
Person sender = personRepository.saveAndFlush(
Person.builder().firstName("Walter").lastName("Raddatz").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Schmidt");
assertThat(rows).hasSize(1);
Boolean senderMatched = (Boolean) rows.get(0)[3];
assertThat(senderMatched).isFalse();
}
@Test
void sender_matched_is_false_when_document_has_no_sender() {
Document doc = documentRepository.saveAndFlush(document("Brief von unbekannt"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
Boolean senderMatched = (Boolean) rows.get(0)[3];
assertThat(senderMatched).isFalse();
}
// ─── Receiver match ────────────────────────────────────────────────────────
@Test
void matched_receiver_ids_contains_uuid_of_matching_receiver() {
Person receiver = personRepository.saveAndFlush(
Person.builder().firstName("Anna").lastName("Schmidt").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.receivers(Set.of(receiver))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Schmidt");
assertThat(rows).hasSize(1);
String receiverIds = (String) rows.get(0)[4];
assertThat(receiverIds).contains(receiver.getId().toString());
}
@Test
void matched_receiver_ids_is_null_when_no_receiver_matches() {
Person receiver = personRepository.saveAndFlush(
Person.builder().firstName("Anna").lastName("Schmidt").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.receivers(Set.of(receiver))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Raddatz");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[4]).isNull();
}
// ─── Tag match ─────────────────────────────────────────────────────────────
@Test
void matched_tag_ids_contains_uuid_of_matching_tag() {
Tag tag = tagRepository.saveAndFlush(Tag.builder().name("Familiengeschichte").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Dokument")
.originalFilename("dok.pdf")
.status(DocumentStatus.UPLOADED)
.tags(Set.of(tag))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Familiengeschichte");
assertThat(rows).hasSize(1);
String tagIds = (String) rows.get(0)[5];
assertThat(tagIds).contains(tag.getId().toString());
}
@Test
void matched_tag_ids_is_null_when_no_tag_matches() {
Tag tag = tagRepository.saveAndFlush(Tag.builder().name("Familiengeschichte").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Dokument")
.originalFilename("dok.pdf")
.status(DocumentStatus.UPLOADED)
.tags(Set.of(tag))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[5]).isNull();
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private Document document(String title) {
return Document.builder()
.title(title)
.originalFilename(title.replace(" ", "_") + ".pdf")
.status(DocumentStatus.UPLOADED)
.build();
}
private UUID annotation(UUID documentId) {
DocumentAnnotation ann = annotationRepository.save(DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(1)
.x(0.1).y(0.2).width(0.3).height(0.4)
.color("#00C7B1")
.build());
em.flush();
return ann.getId();
}
private TranscriptionBlock block(UUID documentId, UUID annotationId, String text, int order) {
return TranscriptionBlock.builder()
.documentId(documentId)
.annotationId(annotationId)
.text(text)
.sortOrder(order)
.build();
}
}

View File

@@ -5,6 +5,7 @@ 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.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
@@ -51,12 +52,12 @@ class DocumentServiceSortTest {
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(newer, older));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC");
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
assertThat(result).hasSize(2);
assertThat(result.get(0).getId()).isEqualTo(id2); // newer doc first
assertThat(result.documents()).hasSize(2);
assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first
}
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
@@ -73,11 +74,11 @@ class DocumentServiceSortTest {
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1)); // unordered from DB
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
// Expect: rank order restored (id1 first)
assertThat(result.get(0).getId()).isEqualTo(id1);
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
}
@Test
@@ -92,9 +93,9 @@ class DocumentServiceSortTest {
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null);
assertThat(result.get(0).getId()).isEqualTo(id1);
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
}
}

View File

@@ -6,11 +6,14 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.dto.MatchOffset;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
@@ -22,6 +25,7 @@ import org.springframework.data.domain.Sort;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDate;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -1287,11 +1291,11 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(withSender, noSender));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc");
assertThat(result).hasSize(2);
assertThat(result).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
assertThat(result.documents()).hasSize(2);
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
}
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
@@ -1307,10 +1311,10 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(noReceivers, withReceiver));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc");
assertThat(result).extracting(Document::getTitle)
assertThat(result.documents()).extracting(Document::getTitle)
.containsExactly("Has Receiver", "No Receivers");
}
@@ -1329,11 +1333,99 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(docNullName, docSmith));
List<Document> result = documentService.searchDocuments(
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc");
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result).extracting(Document::getTitle)
assertThat(result.documents()).extracting(Document::getTitle)
.containsExactly("smith doc", "Null lastname doc");
}
// ─── searchDocuments — match data enrichment ──────────────────────────────
@Test
void searchDocuments_withTextQuery_includesMatchDataWithTitleOffsets() {
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).title("Brief an Anna").build();
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
assertThat(result.matchData()).containsKey(docId);
SearchMatchData md = result.matchData().get(docId);
assertThat(md.titleOffsets()).hasSize(1);
assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
}
@Test
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null);
assertThat(result.matchData()).isEmpty();
}
@Test
void searchDocuments_withTextQuery_includesTranscriptionSnippetWhenPresent() {
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).title("Dok").build();
// Simulate ts_headline output with sentinel markers around the matched word
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
SearchMatchData md = result.matchData().get(docId);
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
}
// ─── parseHighlight unit tests ────────────────────────────────────────────
@Test
void parseHighlight_returnsNull_whenInputIsNull() {
assertThat(DocumentService.parseHighlight(null)).isNull();
}
@Test
void parseHighlight_returnsCleanTextAndEmptyOffsets_whenNoSentinels() {
DocumentService.ParsedHighlight result = DocumentService.parseHighlight("plain text");
assertThat(result.cleanText()).isEqualTo("plain text");
assertThat(result.offsets()).isEmpty();
}
@Test
void parseHighlight_extractsOffsetAndStripsDelimiters() {
// \u0001 = start sentinel, \u0002 = stop sentinel
DocumentService.ParsedHighlight result = DocumentService.parseHighlight("Das \u0001furchtbare\u0002 Wort");
assertThat(result.cleanText()).isEqualTo("Das furchtbare Wort");
assertThat(result.offsets()).containsExactly(new MatchOffset(4, 10)); // "furchtbare" at pos 4, len 10
}
@Test
void parseHighlight_handlesMultipleHighlightedTerms() {
DocumentService.ParsedHighlight result =
DocumentService.parseHighlight("\u0001Hallo\u0002 und \u0001Welt\u0002");
assertThat(result.cleanText()).isEqualTo("Hallo und Welt");
assertThat(result.offsets()).containsExactly(
new MatchOffset(0, 5), // "Hallo"
new MatchOffset(10, 4) // "Welt"
);
}
}

View File

@@ -78,6 +78,8 @@
"docs_empty_btn_clear": "Alle Filter löschen",
"docs_list_from": "Von",
"docs_list_to": "An",
"docs_list_content": "Inhalt",
"docs_list_summary": "Zusammenfassung",
"docs_list_unknown": "Unbekannt",
"docs_group_undated": "Undatiert",
"docs_group_unknown": "Unbekannt",

View File

@@ -78,6 +78,8 @@
"docs_empty_btn_clear": "Clear all filters",
"docs_list_from": "From",
"docs_list_to": "To",
"docs_list_content": "Content",
"docs_list_summary": "Summary",
"docs_list_unknown": "Unknown",
"docs_group_undated": "Undated",
"docs_group_unknown": "Unknown",

View File

@@ -78,6 +78,8 @@
"docs_empty_btn_clear": "Borrar todos los filtros",
"docs_list_from": "De",
"docs_list_to": "Para",
"docs_list_content": "Contenido",
"docs_list_summary": "Resumen",
"docs_list_unknown": "Desconocido",
"docs_group_undated": "Sin fecha",
"docs_group_unknown": "Desconocido",

View File

@@ -56,21 +56,20 @@ onMount(async () => {
await renderer.init();
});
// Wire DOM elements to the renderer after they mount
$effect(() => {
if (canvasEl && textLayerEl) {
renderer.setElements(canvasEl, textLayerEl);
}
});
$effect(() => {
if (renderer.pdfjsReady && url) {
renderer.loadDocument(url);
}
});
// Wire DOM elements to the renderer and trigger rendering.
// canvasEl is read synchronously so Svelte tracks it as a dependency:
// when the canvas reappears after the loading spinner (loading → false),
// this effect re-fires and renders the already-loaded PDF.
$effect(() => {
// Read scale and currentPage synchronously so Svelte tracks them as dependencies.
if (!canvasEl || !textLayerEl) return;
renderer.setElements(canvasEl, textLayerEl);
// Also track currentPage and scale so page-nav / zoom re-renders work.
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
renderer.renderCurrentPage().then(() => renderer.prerender());
}

View File

@@ -628,6 +628,22 @@ export interface paths {
patch: operations["editComment"];
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteAnnotation"];
options?: never;
head?: never;
patch: operations["updateAnnotation"];
trace?: never;
};
"/api/users/search": {
parameters: {
query?: never;
@@ -1060,22 +1076,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteAnnotation"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -1440,6 +1440,16 @@ export interface components {
label?: string;
enrolled?: boolean;
};
UpdateAnnotationDTO: {
/** Format: double */
x?: number;
/** Format: double */
y?: number;
/** Format: double */
width?: number;
/** Format: double */
height?: number;
};
StatsDTO: {
/** Format: int64 */
totalPersons?: number;
@@ -1451,17 +1461,17 @@ export interface components {
/** Format: uuid */
id?: string;
displayName?: string;
personType?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
documentCount?: number;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
alias?: string;
notes?: string;
/** Format: int64 */
documentCount?: number;
personType?: string;
};
TrainingInfoResponse: {
/** Format: int32 */
@@ -1508,6 +1518,8 @@ export interface components {
/** Format: int64 */
totalElements?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -1516,8 +1528,6 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -1578,9 +1588,28 @@ export interface components {
totalPages?: number;
};
DocumentSearchResult: {
documents?: components["schemas"]["Document"][];
documents: components["schemas"]["Document"][];
/** Format: int64 */
total?: number;
total: number;
matchData: {
[key: string]: components["schemas"]["SearchMatchData"];
};
};
MatchOffset: {
/** Format: int32 */
start: number;
/** Format: int32 */
length: number;
};
SearchMatchData: {
transcriptionSnippet?: string;
titleOffsets: components["schemas"]["MatchOffset"][];
senderMatched: boolean;
matchedReceiverIds: string[];
matchedTagIds: string[];
snippetOffsets: components["schemas"]["MatchOffset"][];
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
IncompleteDocumentDTO: {
/** Format: uuid */
@@ -2938,8 +2967,8 @@ export interface operations {
};
};
responses: {
/** @description OK */
200: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
@@ -2995,6 +3024,54 @@ export interface operations {
};
};
};
deleteAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateAnnotationDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentAnnotation"];
};
};
};
};
search: {
parameters: {
query?: {
@@ -3425,7 +3502,7 @@ export interface operations {
/** @description Filter by document status */
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
/** @description Sort field */
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE";
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "RELEVANCE";
/** @description Sort direction: ASC or DESC */
dir?: string;
};
@@ -3602,25 +3679,4 @@ export interface operations {
};
};
};
deleteAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { applyOffsets } from './search';
describe('applyOffsets', () => {
it('returns single plain segment when offsets is empty', () => {
expect(applyOffsets('Hallo Welt', [])).toEqual([{ text: 'Hallo Welt', highlight: false }]);
});
it('highlights a single term at the start', () => {
expect(applyOffsets('Brief an Anna', [{ start: 0, length: 5 }])).toEqual([
{ text: 'Brief', highlight: true },
{ text: ' an Anna', highlight: false }
]);
});
it('highlights a term in the middle', () => {
expect(applyOffsets('Der Brief von Anna', [{ start: 4, length: 5 }])).toEqual([
{ text: 'Der ', highlight: false },
{ text: 'Brief', highlight: true },
{ text: ' von Anna', highlight: false }
]);
});
it('highlights a term at the end', () => {
expect(applyOffsets('Brief an Anna', [{ start: 9, length: 4 }])).toEqual([
{ text: 'Brief an ', highlight: false },
{ text: 'Anna', highlight: true }
]);
});
it('handles two non-overlapping offsets in order', () => {
expect(
applyOffsets('Anna und Brief', [
{ start: 0, length: 4 },
{ start: 9, length: 5 }
])
).toEqual([
{ text: 'Anna', highlight: true },
{ text: ' und ', highlight: false },
{ text: 'Brief', highlight: true }
]);
});
it('merges overlapping offsets into the longest span', () => {
// [0,7) and [3,9) overlap → merged [0,max(7,9)) = [0,9) = "Hello wor"
expect(
applyOffsets('Hello world', [
{ start: 0, length: 7 },
{ start: 3, length: 6 }
])
).toEqual([
{ text: 'Hello wor', highlight: true },
{ text: 'ld', highlight: false }
]);
});
it('merges adjacent (touching) offsets', () => {
// [0,3) and [3,6) are adjacent → merged [0,6)
expect(
applyOffsets('Hallo Welt', [
{ start: 0, length: 3 },
{ start: 3, length: 3 }
])
).toEqual([
{ text: 'Hallo ', highlight: true },
{ text: 'Welt', highlight: false }
]);
});
it('clamps offset that extends beyond text length', () => {
expect(applyOffsets('Hi', [{ start: 0, length: 100 }])).toEqual([
{ text: 'Hi', highlight: true }
]);
});
it('ignores a completely out-of-bounds offset', () => {
expect(applyOffsets('Hi', [{ start: 10, length: 5 }])).toEqual([
{ text: 'Hi', highlight: false }
]);
});
it('sorts unsorted offsets correctly', () => {
// Offsets provided in reverse order: second term first
expect(
applyOffsets('Anna und Brief', [
{ start: 9, length: 5 },
{ start: 0, length: 4 }
])
).toEqual([
{ text: 'Anna', highlight: true },
{ text: ' und ', highlight: false },
{ text: 'Brief', highlight: true }
]);
});
it('clamps negative start to 0 and highlights from the beginning', () => {
// start = -2, length = 5 → effective range [-2, 3) → clamped to [0, 3)
expect(applyOffsets('Hello', [{ start: -2, length: 5 }])).toEqual([
{ text: 'Hel', highlight: true },
{ text: 'lo', highlight: false }
]);
});
it('ignores offset whose end is also negative', () => {
// start = -5, length = 2 → end = -3, completely before text
expect(applyOffsets('Hi', [{ start: -5, length: 2 }])).toEqual([
{ text: 'Hi', highlight: false }
]);
});
});

View File

@@ -0,0 +1,46 @@
export type TextSegment = { text: string; highlight: boolean };
export type MatchOffset = { start: number; length: number };
/**
* Converts a flat string and a list of character-level highlight offsets into
* an array of text segments that can be rendered without {@html}.
*
* Offsets are sorted and merged (overlapping spans become the longest enclosing
* span) before processing. Out-of-bounds offsets are clamped or dropped.
*
* @param text The display text (no delimiter characters).
* @param offsets Character offsets produced by the backend (Java char positions,
* compatible with JavaScript String indexing).
*/
export function applyOffsets(text: string, offsets: MatchOffset[]): TextSegment[] {
if (!offsets.length) return [{ text, highlight: false }];
// Sort by start position and merge overlapping / adjacent spans
const sorted = [...offsets].sort((a, b) => a.start - b.start);
const merged: { start: number; end: number }[] = [];
for (const { start, length } of sorted) {
const end = start + length;
if (end <= 0 || start >= text.length) continue; // completely out of bounds
const clampedStart = Math.max(0, start);
const clampedEnd = Math.min(text.length, end);
const last = merged[merged.length - 1];
if (!last || clampedStart > last.end) {
merged.push({ start: clampedStart, end: clampedEnd });
} else {
last.end = Math.max(last.end, clampedEnd);
}
}
if (!merged.length) return [{ text, highlight: false }];
const segments: TextSegment[] = [];
let pos = 0;
for (const { start, end } of merged) {
if (pos < start) segments.push({ text: text.slice(pos, start), highlight: false });
segments.push({ text: text.slice(start, end), highlight: true });
pos = end;
}
if (pos < text.length) segments.push({ text: text.slice(pos), highlight: false });
return segments;
}

View File

@@ -5,6 +5,7 @@ import type { components } from '$lib/generated/api';
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type StatsDTO = components['schemas']['StatsDTO'];
type Document = components['schemas']['Document'];
type SearchMatchData = components['schemas']['SearchMatchData'];
export async function load({ url, fetch }) {
const q = url.searchParams.get('q') || '';
@@ -60,9 +61,14 @@ export async function load({ url, fetch }) {
throw redirect(302, '/login');
}
const searchResult = docsResult?.data as { documents?: Document[]; total?: number } | null;
const searchResult = docsResult?.data as {
documents?: Document[];
total?: number;
matchData?: Record<string, SearchMatchData>;
} | null;
const documents: Document[] = searchResult?.documents ?? [];
const total: number = searchResult?.total ?? 0;
const matchData: Record<string, SearchMatchData> = searchResult?.matchData ?? {};
const allPersons = (personsResult.data ?? []) as {
id: string;
firstName: string;
@@ -99,6 +105,7 @@ export async function load({ url, fetch }) {
isDashboard,
documents,
total,
matchData,
stats,
incompleteDocs,
recentDocs,
@@ -116,6 +123,7 @@ export async function load({ url, fetch }) {
isDashboard,
documents: [],
total: 0,
matchData: {} as Record<string, SearchMatchData>,
stats: null,
incompleteDocs: [],
recentDocs: [],

View File

@@ -140,6 +140,7 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
total={data.total ?? 0}
q={q}
sort={sort}
matchData={data.matchData ?? {}}
/>
{/if}
</main>

View File

@@ -4,6 +4,8 @@ import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { groupDocuments } from '$lib/utils/groupDocuments';
import GroupDivider from '$lib/components/GroupDivider.svelte';
import { applyOffsets } from '$lib/search';
import type { components } from '$lib/generated/api';
let {
documents,
@@ -11,7 +13,8 @@ let {
error,
total = 0,
q = '',
sort
sort,
matchData = {}
}: {
documents: {
id: string;
@@ -19,8 +22,13 @@ let {
originalFilename: string;
documentDate?: string | null;
location?: string | null;
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
sender?: {
id?: string;
firstName?: string | null;
lastName: string;
displayName: string;
} | null;
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
tags?: { id: string; name: string }[];
}[];
canWrite: boolean;
@@ -28,6 +36,7 @@ let {
total?: number;
q?: string;
sort?: string;
matchData?: Record<string, components['schemas']['SearchMatchData']>;
} = $props();
const fallbackLabel = $derived(
@@ -75,6 +84,17 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{/if}
<ul class="divide-y divide-line-2">
{#each group.documents as doc (doc.id)}
{@const titleText = doc.title || doc.originalFilename}
{@const match = matchData?.[doc.id]}
{@const titleOffsets = match?.titleOffsets ?? []}
{@const titleSegments = applyOffsets(titleText, titleOffsets)}
{@const snippet = match?.transcriptionSnippet}
{@const snippetSegments = snippet ? applyOffsets(snippet, match?.snippetOffsets ?? []) : null}
{@const summary = match?.summarySnippet}
{@const summarySegments = summary ? applyOffsets(summary, match?.summaryOffsets ?? []) : null}
{@const senderMatched = match?.senderMatched ?? false}
{@const matchedReceiverIds = new Set(match?.matchedReceiverIds ?? [])}
{@const matchedTagIds = new Set(match?.matchedTagIds ?? [])}
<li class="group transition-colors duration-200 hover:bg-muted/50">
<a href="/documents/{doc.id}" class="block p-6">
<div class="flex flex-col gap-6 sm:flex-row">
@@ -82,7 +102,12 @@ const showDividers = $derived(groupedDocuments.length >= 2);
<div class="flex-1">
<div class="mb-2 flex items-baseline justify-between">
<h3 class="font-serif text-xl font-medium text-ink group-hover:underline">
{doc.title || doc.originalFilename}
{#each titleSegments as seg, i (i)}
{#if seg.highlight}<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>{:else}{seg.text}{/if}
{/each}
</h3>
</div>
@@ -110,6 +135,42 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{/if}
</div>
{#if snippetSegments}
<div class="mb-4 flex items-baseline gap-2">
<span
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_content()}</span
>
<p
data-testid="search-snippet"
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
>
{#each snippetSegments as seg, i (i)}{#if seg.highlight}<mark
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>{:else}{seg.text}{/if}{/each}
</p>
</div>
{/if}
{#if summarySegments}
<div class="mb-4 flex items-baseline gap-2">
<span
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_summary()}</span
>
<p
data-testid="search-summary"
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
>
{#each summarySegments as seg, i (i)}{#if seg.highlight}<mark
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>{:else}{seg.text}{/if}{/each}
</p>
</div>
{/if}
<!-- Sender/Receiver Info -->
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
<div class="flex items-baseline">
@@ -118,7 +179,15 @@ const showDividers = $derived(groupedDocuments.length >= 2);
>{m.docs_list_from()}</span
>
{#if doc.sender}
<span class="text-ink">{doc.sender.displayName}</span>
{#if senderMatched}
<mark
data-testid="sender-match"
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{doc.sender.displayName}</mark
>
{:else}
<span class="text-ink">{doc.sender.displayName}</span>
{/if}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
@@ -130,7 +199,18 @@ const showDividers = $derived(groupedDocuments.length >= 2);
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-ink">
{doc.receivers.map((p) => p.displayName).join(', ')}
{#each doc.receivers as receiver, ri (receiver.id ?? ri)}
{#if ri > 0}<span>, </span>{/if}
{#if receiver.id && matchedReceiverIds.has(receiver.id)}
<mark
data-testid="receiver-match"
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{receiver.displayName}</mark
>
{:else}
{receiver.displayName}
{/if}
{/each}
</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
@@ -144,14 +224,18 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{#each doc.tags as tag (tag.id)}
<button
type="button"
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-muted px-2 py-1 text-[10px] font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
class="relative z-10 inline-flex cursor-pointer items-center rounded px-2 py-1 text-[10px] font-bold tracking-widest uppercase transition-colors hover:bg-primary hover:text-primary-fg {matchedTagIds.has(tag.id) ? 'bg-muted text-ink underline decoration-brand-navy decoration-2 underline-offset-2' : 'bg-muted text-ink'}"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
goto(`/?tag=${encodeURIComponent(tag.name)}`);
}}
>
{tag.name}
{#if matchedTagIds.has(tag.id)}
<span data-testid="tag-match">{tag.name}</span>
{:else}
{tag.name}
{/if}
</button>
{/each}
</div>

View File

@@ -12,14 +12,20 @@ const baseProps = {
canWrite: false,
error: null,
total: 0,
q: ''
q: '',
matchData: {} as Record<
string,
import('$lib/generated/api').components['schemas']['SearchMatchData']
>
};
type DocOverrides = {
id?: string;
title?: string;
documentDate?: string | null;
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
sender?: { id?: string; firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
tags?: { id: string; name: string }[];
};
const makeDoc = (overrides: DocOverrides = {}) => ({
@@ -30,7 +36,12 @@ const makeDoc = (overrides: DocOverrides = {}) => ({
documentDate: '2024-03-15',
location: null,
sender: null,
receivers: [] as { firstName?: string | null; lastName: string; displayName: string }[],
receivers: [] as {
id?: string;
firstName?: string | null;
lastName: string;
displayName: string;
}[],
tags: [],
...overrides
});
@@ -115,3 +126,248 @@ describe('DocumentList group headers', () => {
await expect.element(links.nth(1)).toBeInTheDocument();
});
});
// ─── Match data: snippet and title highlighting ───────────────────────────────
describe('DocumentList match snippets and highlights', () => {
it('shows transcription snippet when matchData has one for the document', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: 'Er schrieb einen langen Brief',
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument();
});
it('does not show snippet section when matchData has no entry for the document', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, { ...baseProps, documents: [doc], total: 1, matchData: {} });
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
});
it('renders a <mark> element when titleOffsets are present', async () => {
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [{ start: 0, length: 5 }], // "Brief"
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
// The word "Brief" should be inside a <mark> element
const mark = page.getByRole('mark');
await expect.element(mark).toBeInTheDocument();
await expect.element(mark).toHaveTextContent('Brief');
});
it('renders title as plain text when titleOffsets is empty', async () => {
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
await expect.element(page.getByRole('mark')).not.toBeInTheDocument();
await expect.element(page.getByText('Brief an Anna')).toBeInTheDocument();
});
it('renders <mark> inside snippet when snippetOffsets are present', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: 'Er schrieb einen Brief',
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [{ start: 17, length: 5 }], // "Brief"
summaryOffsets: []
}
}
});
const snippet = page.getByTestId('search-snippet');
await expect.element(snippet).toBeInTheDocument();
const mark = snippet.getByRole('mark');
await expect.element(mark).toBeInTheDocument();
await expect.element(mark).toHaveTextContent('Brief');
});
it('renders snippet as plain text when snippetOffsets is empty', async () => {
const doc = makeDoc({ id: 'doc1' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: 'Er schrieb einen Brief',
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
const snippet = page.getByTestId('search-snippet');
await expect.element(snippet).toBeInTheDocument();
// No mark elements inside the snippet when offsets is empty
await expect.element(snippet.getByRole('mark')).not.toBeInTheDocument();
});
it('visually marks sender when senderMatched is true', async () => {
const doc = makeDoc({
id: 'doc1',
sender: {
id: 'sender-1',
firstName: 'Walter',
lastName: 'Raddatz',
displayName: 'Walter Raddatz'
}
});
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: true,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
const senderMark = page.getByTestId('sender-match');
await expect.element(senderMark).toBeInTheDocument();
await expect.element(senderMark).toHaveTextContent('Walter Raddatz');
});
it('does not mark sender when senderMatched is false', async () => {
const doc = makeDoc({
id: 'doc1',
sender: {
id: 'sender-1',
firstName: 'Walter',
lastName: 'Raddatz',
displayName: 'Walter Raddatz'
}
});
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
await expect.element(page.getByTestId('sender-match')).not.toBeInTheDocument();
});
it('visually marks matched receiver when their id is in matchedReceiverIds', async () => {
const doc = makeDoc({
id: 'doc1',
receivers: [
{ id: 'p-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
{ id: 'p-2', firstName: 'Karl', lastName: 'Bauer', displayName: 'Karl Bauer' }
]
});
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: ['p-1'],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
}
}
});
// Only Anna Schmidt should be marked
const receiverMark = page.getByTestId('receiver-match');
await expect.element(receiverMark).toBeInTheDocument();
await expect.element(receiverMark).toHaveTextContent('Anna Schmidt');
});
it('visually marks matched tag when its id is in matchedTagIds', async () => {
const doc = makeDoc({
id: 'doc1',
tags: [
{ id: 'tag-1', name: 'Familiengeschichte' },
{ id: 'tag-2', name: 'Reise' }
]
});
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
matchData: {
doc1: {
transcriptionSnippet: undefined,
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: ['tag-1'],
snippetOffsets: [],
summaryOffsets: []
}
}
});
const tagMark = page.getByTestId('tag-match');
await expect.element(tagMark).toBeInTheDocument();
await expect.element(tagMark).toHaveTextContent('Familiengeschichte');
});
});

View File

@@ -22,8 +22,23 @@ const emptyData = {
canWrite: true,
canAnnotate: false,
isDashboard: false,
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
filters: {
q: '',
from: '',
to: '',
senderId: '',
receiverId: '',
tags: [],
sort: 'DATE' as const,
dir: 'desc' as const,
tagQ: ''
},
documents: [],
total: 0,
matchData: {} as Record<
string,
import('$lib/generated/api').components['schemas']['SearchMatchData']
>,
incompleteDocs: [],
recentDocs: [],
stats: null,