Compare commits

..

1 Commits

Author SHA1 Message Date
Marcel
5bd7f0d486 docs(#240): add Mission Control Strip spec and pattern alternatives
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m25s
CI / Backend Unit Tests (push) Failing after 2m38s
CI / Unit & Component Tests (pull_request) Failing after 2m11s
CI / Backend Unit Tests (pull_request) Failing after 8h41m14s
Adds the design decision record for how to expand the dashboard without
pushing content below the fold: a full-width 3-column strip (Segmentierung /
Transkription / Lesefertig) below the existing grid.

- dashboard-expansion-patterns.html — four pattern alternatives evaluated
  (Tabs, Accordion, Mission Control, Priority Queue) with annotated mockups,
  engagement feature proposal, and final recommendation.
- mission-control-strip-final.html — clean implementation blueprint with
  pipeline diagram, column definitions, seeded-weekly-shuffle sorting,
  expert-flag escape hatch, all Tailwind impl-ref values, and backend
  contracts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:48:27 +02:00
40 changed files with 107 additions and 2359 deletions

View File

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

View File

@@ -1,47 +0,0 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.TranscriptionQueueService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Serves the three Mission Control Strip columns for the dashboard.
* All endpoints require READ_ALL — same guard as the rest of the archive.
*/
@RestController
@RequestMapping("/api/transcription")
@RequiredArgsConstructor
@RequirePermission(Permission.READ_ALL)
public class TranscriptionQueueController {
private final TranscriptionQueueService transcriptionQueueService;
@GetMapping("/segmentation-queue")
public ResponseEntity<List<TranscriptionQueueItemDTO>> getSegmentationQueue() {
return ResponseEntity.ok(transcriptionQueueService.getSegmentationQueue());
}
@GetMapping("/transcription-queue")
public ResponseEntity<List<TranscriptionQueueItemDTO>> getTranscriptionQueue() {
return ResponseEntity.ok(transcriptionQueueService.getTranscriptionQueue());
}
@GetMapping("/ready-to-read")
public ResponseEntity<List<TranscriptionQueueItemDTO>> getReadyToRead() {
return ResponseEntity.ok(transcriptionQueueService.getReadyToReadQueue());
}
@GetMapping("/weekly-stats")
public ResponseEntity<TranscriptionWeeklyStatsDTO> getWeeklyStats() {
return ResponseEntity.ok(transcriptionQueueService.getWeeklyStats());
}
}

View File

@@ -1,35 +1,16 @@
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(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Document> documents,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long total,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Map<UUID, SearchMatchData> matchData
) {
public record DocumentSearchResult(List<Document> documents, long total) {
/**
* 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).
* Creates a result where total equals the list size.
* 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 withMatchData(documents, Map.of());
return new DocumentSearchResult(documents, documents.size());
}
}

View File

@@ -1,14 +0,0 @@
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

@@ -1,67 +0,0 @@
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

@@ -1,19 +0,0 @@
package org.raddatz.familienarchiv.dto;
import java.time.LocalDate;
import java.util.UUID;
/**
* A single row in one of the three Mission Control Strip queues.
* Annotation/block counts drive the per-document mini progress bar
* in the Transkription column and the percentage label in Lesefertig.
*/
public record TranscriptionQueueItemDTO(
UUID id,
String title,
LocalDate documentDate,
boolean needsExpert,
int annotationCount,
int textedBlockCount,
int reviewedBlockCount
) {}

View File

@@ -1,12 +0,0 @@
package org.raddatz.familienarchiv.dto;
/**
* Weekly activity pulse for the Mission Control Strip column headers.
* Counts documents that received new work in each pipeline stage
* during the last 7 days.
*/
public record TranscriptionWeeklyStatsDTO(
long segmentationCount,
long transcriptionCount,
long readyCount
) {}

View File

@@ -97,11 +97,6 @@ public class Document {
@Builder.Default
private ScriptType scriptType = ScriptType.UNKNOWN;
@Column(name = "needs_expert", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private boolean needsExpert = false;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
@Builder.Default

View File

@@ -83,166 +83,10 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@Query(nativeQuery = true, value = """
SELECT d.id 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
WHERE d.search_vector @@ q.pq
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
WHERE d.search_vector @@ websearch_to_tsquery('german', :query)
ORDER BY ts_rank(d.search_vector, websearch_to_tsquery('german', :query)) 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);
// --- Mission Control Strip queues ---
/** Documents with no annotations — Segmentierung column. */
@Query(nativeQuery = true, value = """
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert,
0 AS annotationCount, 0 AS textedBlockCount, 0 AS reviewedBlockCount
FROM documents d
WHERE d.status NOT IN ('PLACEHOLDER')
AND NOT EXISTS (SELECT 1 FROM document_annotations da WHERE da.document_id = d.id)
ORDER BY d.needs_expert ASC,
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
LIMIT :limit
""")
List<Object[]> findSegmentationQueue(@Param("limit") int limit);
/** Documents with annotations but not yet fully reviewed — Transkription column. */
@Query(nativeQuery = true, value = """
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert,
COUNT(DISTINCT da.id) AS annotationCount,
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount,
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount
FROM documents d
JOIN document_annotations da ON da.document_id = d.id
LEFT JOIN transcription_blocks tb ON tb.document_id = d.id
GROUP BY d.id, d.title, d.meta_date, d.needs_expert
HAVING COUNT(DISTINCT da.id) > 0
AND (
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) = 0
OR (
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
NULLIF(COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END), 0)
) < 0.90
)
ORDER BY d.needs_expert ASC,
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) DESC,
HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text)
LIMIT :limit
""")
List<Object[]> findTranscriptionQueue(@Param("limit") int limit);
/** Documents with reviewed_pct >= 90 % — Lesefertig column. */
@Query(nativeQuery = true, value = """
SELECT d.id, d.title, d.meta_date AS documentDate, d.needs_expert AS needsExpert,
COUNT(DISTINCT da.id) AS annotationCount,
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) AS textedBlockCount,
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END) AS reviewedBlockCount
FROM documents d
JOIN document_annotations da ON da.document_id = d.id
LEFT JOIN transcription_blocks tb ON tb.document_id = d.id
GROUP BY d.id, d.title, d.meta_date, d.needs_expert
HAVING COUNT(DISTINCT da.id) > 0
AND COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END) > 0
AND (
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END)
) >= 0.90
ORDER BY (
COUNT(DISTINCT CASE WHEN tb.reviewed = true THEN tb.id END)::float /
COUNT(DISTINCT CASE WHEN tb.text IS NOT NULL AND tb.text <> '' THEN tb.id END)
) DESC
LIMIT :limit
""")
List<Object[]> findReadyToReadQueue(@Param("limit") int limit);
/** Weekly pulse: distinct documents that received new work in each pipeline stage. */
@Query(nativeQuery = true, value = """
SELECT
(SELECT COUNT(DISTINCT da.document_id) FROM document_annotations da
WHERE da.created_at >= NOW() - INTERVAL '7 days') AS segmentationCount,
(SELECT COUNT(DISTINCT tb.document_id) FROM transcription_blocks tb
WHERE tb.created_at >= NOW() - INTERVAL '7 days'
AND tb.text IS NOT NULL AND tb.text <> '') AS transcriptionCount,
(SELECT COUNT(DISTINCT tb.document_id) FROM transcription_blocks tb
WHERE tb.updated_at >= NOW() - INTERVAL '7 days'
AND tb.reviewed = true) AS readyCount
""")
Object[] findWeeklyStats();
}

View File

@@ -3,13 +3,10 @@ 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;
@@ -293,13 +290,13 @@ public class DocumentService {
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
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) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
if (rankedIds.isEmpty()) return List.of();
}
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
@@ -315,13 +312,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(sorted, enrichWithMatchData(sorted, text));
return sortByFirstReceiver(results, dir);
}
if (sort == DocumentSort.SENDER) {
List<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortBySender(results, dir);
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
return sortBySender(results, dir);
}
// RELEVANCE: default when text present and no explicit sort given
@@ -330,16 +325,14 @@ 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);
List<Document> sorted = results.stream()
return 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);
List<Document> results = documentRepository.findAll(spec, springSort);
return DocumentSearchResult.withMatchData(results, enrichWithMatchData(results, text));
return documentRepository.findAll(spec, springSort);
}
private Sort resolveSort(DocumentSort sort, String dir) {
@@ -577,13 +570,6 @@ public class DocumentService {
return parsed != null ? parsed.title() : stripExtension(filename);
}
@Transactional
public Document toggleNeedsExpert(UUID documentId) {
Document doc = getDocumentById(documentId);
doc.setNeedsExpert(!doc.isNeedsExpert());
return documentRepository.save(doc);
}
private static String tryParseDate(String s) {
if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
int m = Integer.parseInt(s.substring(5, 7));
@@ -598,93 +584,6 @@ 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

@@ -1,100 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig)
* and the weekly activity pulse used by the column headers.
*/
@Service
@RequiredArgsConstructor
public class TranscriptionQueueService {
private static final int DEFAULT_QUEUE_SIZE = 5;
private final DocumentRepository documentRepository;
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::mapRow)
.toList();
}
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::mapRow)
.toList();
}
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::mapRow)
.toList();
}
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
Object[] row = documentRepository.findWeeklyStats();
return new TranscriptionWeeklyStatsDTO(
toLong(row[0]),
toLong(row[1]),
toLong(row[2])
);
}
// --- mapping helpers ---
private TranscriptionQueueItemDTO mapRow(Object[] row) {
UUID id = toUUID(row[0]);
String title = (String) row[1];
LocalDate documentDate = toLocalDate(row[2]);
boolean needsExpert = toBoolean(row[3]);
int annotationCount = toInt(row[4]);
int textedBlockCount = toInt(row[5]);
int reviewedBlockCount = toInt(row[6]);
return new TranscriptionQueueItemDTO(id, title, documentDate, needsExpert,
annotationCount, textedBlockCount, reviewedBlockCount);
}
private UUID toUUID(Object o) {
if (o instanceof UUID u) return u;
return UUID.fromString(o.toString());
}
private LocalDate toLocalDate(Object o) {
if (o == null) return null;
if (o instanceof LocalDate d) return d;
if (o instanceof java.sql.Date d) return d.toLocalDate();
return LocalDate.parse(o.toString());
}
private boolean toBoolean(Object o) {
if (o instanceof Boolean b) return b;
return Boolean.parseBoolean(o.toString());
}
private int toInt(Object o) {
if (o == null) return 0;
if (o instanceof Number n) return n.intValue();
if (o instanceof BigDecimal bd) return bd.intValue();
return Integer.parseInt(o.toString());
}
private long toLong(Object o) {
if (o == null) return 0L;
if (o instanceof Number n) return n.longValue();
if (o instanceof BigDecimal bd) return bd.longValue();
return Long.parseLong(o.toString());
}
}

View File

@@ -1,4 +0,0 @@
-- 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 +0,0 @@
ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -1,7 +1,6 @@
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;
@@ -25,7 +24,6 @@ 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;
@@ -63,7 +61,7 @@ class DocumentControllerTest {
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk());
@@ -73,7 +71,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(DocumentSearchResult.of(List.of()));
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk());
@@ -106,7 +104,7 @@ class DocumentControllerTest {
@WithMockUser
void search_responseContainsTotalCount() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
@@ -114,28 +112,6 @@ 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

@@ -1,68 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -1,69 +0,0 @@
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,16 +79,6 @@ 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

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

View File

@@ -6,14 +6,11 @@ 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;
@@ -25,7 +22,6 @@ 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;
@@ -1291,11 +1287,11 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(withSender, noSender));
DocumentSearchResult result = documentService.searchDocuments(
List<Document> result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc");
assertThat(result.documents()).hasSize(2);
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
assertThat(result).hasSize(2);
assertThat(result).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
}
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
@@ -1311,10 +1307,10 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(noReceivers, withReceiver));
DocumentSearchResult result = documentService.searchDocuments(
List<Document> result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc");
assertThat(result.documents()).extracting(Document::getTitle)
assertThat(result).extracting(Document::getTitle)
.containsExactly("Has Receiver", "No Receivers");
}
@@ -1333,99 +1329,11 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(docNullName, docSmith));
DocumentSearchResult result = documentService.searchDocuments(
List<Document> 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.documents()).extracting(Document::getTitle)
assertThat(result).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,8 +78,6 @@
"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",
@@ -557,23 +555,5 @@
"training_seg_too_few_blocks": "Mindestens 5 Segmentierungsblöcke erforderlich (aktuell: {available}).",
"transcription_block_segmentation_only": "Nur Segmentierung",
"training_chip_kurrent": "Kurrent-Erkennung",
"training_chip_segmentation": "Segmentierung",
"mission_control_heading": "Was braucht Aufmerksamkeit?",
"mission_control_segmentation_heading": "Rahmen einzeichnen",
"mission_control_segmentation_description": "Textbereiche markieren — keine Vorkenntnisse nötig",
"mission_control_seg_skill_pill": "✓ Ohne Vorkenntnisse",
"mission_control_segmentation_empty": "Alle Dokumente haben bereits Segmentierungsblöcke.",
"mission_control_transcription_heading": "Text eintippen",
"mission_control_transcription_description": "Text abschreiben — Kurrent-Kenntnisse hilfreich",
"mission_control_trans_skill_pill": "Kurrent hilfreich",
"mission_control_transcription_empty": "Keine Dokumente warten auf Transkription.",
"mission_control_ready_heading": "Lesefertig ✓",
"mission_control_ready_description": "Vollständig transkribiert und geprüft",
"mission_control_ready_subtitle": "{count} Dokumente bereit",
"mission_control_ready_empty": "Noch keine Dokumente vollständig transkribiert.",
"mission_control_ready_empty_cta": "Jetzt mitmachen",
"mission_control_weekly_pulse": "↑ +{count} diese Woche",
"mission_control_expert_badge": "Experten gesucht",
"mission_control_blocks_progress": "{texted} / {total} Blöcke",
"mission_control_reviewed_pct": "{pct}% geprüft"
"training_chip_segmentation": "Segmentierung"
}

View File

@@ -78,8 +78,6 @@
"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",
@@ -557,23 +555,5 @@
"training_seg_too_few_blocks": "At least 5 segmentation blocks required (currently: {available}).",
"transcription_block_segmentation_only": "Segmentation only",
"training_chip_kurrent": "Kurrent recognition",
"training_chip_segmentation": "Segmentation",
"mission_control_heading": "What needs attention?",
"mission_control_segmentation_heading": "Draw regions",
"mission_control_segmentation_description": "Mark text areas — no prior knowledge needed",
"mission_control_seg_skill_pill": "✓ No prior knowledge",
"mission_control_segmentation_empty": "All documents already have segmentation blocks.",
"mission_control_transcription_heading": "Type the text",
"mission_control_transcription_description": "Type out text — Kurrent knowledge helpful",
"mission_control_trans_skill_pill": "Kurrent helpful",
"mission_control_transcription_empty": "No documents waiting for transcription.",
"mission_control_ready_heading": "Ready to read ✓",
"mission_control_ready_description": "Fully transcribed and reviewed",
"mission_control_ready_subtitle": "{count} documents ready",
"mission_control_ready_empty": "No documents fully transcribed yet.",
"mission_control_ready_empty_cta": "Start contributing",
"mission_control_weekly_pulse": "↑ +{count} this week",
"mission_control_expert_badge": "Expert needed",
"mission_control_blocks_progress": "{texted} / {total} blocks",
"mission_control_reviewed_pct": "{pct}% reviewed"
"training_chip_segmentation": "Segmentation"
}

View File

@@ -78,8 +78,6 @@
"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",
@@ -557,23 +555,5 @@
"training_seg_too_few_blocks": "Se requieren al menos 5 bloques de segmentación (actualmente: {available}).",
"transcription_block_segmentation_only": "Solo segmentación",
"training_chip_kurrent": "Reconocimiento Kurrent",
"training_chip_segmentation": "Segmentación",
"mission_control_heading": "¿Qué necesita atención?",
"mission_control_segmentation_heading": "Marcar regiones",
"mission_control_segmentation_description": "Marcar áreas de texto — sin conocimientos previos",
"mission_control_seg_skill_pill": "✓ Sin conocimientos previos",
"mission_control_segmentation_empty": "Todos los documentos ya tienen bloques de segmentación.",
"mission_control_transcription_heading": "Escribir el texto",
"mission_control_transcription_description": "Escribir el texto — conocimiento de Kurrent útil",
"mission_control_trans_skill_pill": "Kurrent útil",
"mission_control_transcription_empty": "No hay documentos esperando transcripción.",
"mission_control_ready_heading": "Listo para leer ✓",
"mission_control_ready_description": "Completamente transcrito y revisado",
"mission_control_ready_subtitle": "{count} documentos listos",
"mission_control_ready_empty": "Aún no hay documentos completamente transcritos.",
"mission_control_ready_empty_cta": "Empezar a colaborar",
"mission_control_weekly_pulse": "↑ +{count} esta semana",
"mission_control_expert_badge": "Se busca experto",
"mission_control_blocks_progress": "{texted} / {total} bloques",
"mission_control_reviewed_pct": "{pct}% revisado"
"training_chip_segmentation": "Segmentación"
}

View File

@@ -1,26 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
</script>
<span
class="inline-flex items-center gap-1 rounded border border-purple-200 bg-purple-50 px-2 py-0.5 text-xs font-semibold text-purple-700"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 1.5L1.5 13.5h13L8 1.5z"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
fill="none"
/>
<path d="M8 6v3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" />
</svg>
{m.mission_control_expert_badge()}
</span>

View File

@@ -1,50 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import SegmentationColumn from './SegmentationColumn.svelte';
import TranscriptionColumn from './TranscriptionColumn.svelte';
import ReadyColumn from './ReadyColumn.svelte';
type TranscriptionQueueItemDTO = {
id: string;
title: string;
documentDate?: string;
needsExpert: boolean;
annotationCount: number;
textedBlockCount: number;
reviewedBlockCount: number;
};
type TranscriptionWeeklyStatsDTO = {
segmentationCount: number;
transcriptionCount: number;
readyCount: number;
};
interface Props {
segmentationDocs: TranscriptionQueueItemDTO[];
transcriptionDocs: TranscriptionQueueItemDTO[];
readyDocs: TranscriptionQueueItemDTO[];
weeklyStats: TranscriptionWeeklyStatsDTO | null;
}
let { segmentationDocs, transcriptionDocs, readyDocs, weeklyStats }: Props = $props();
</script>
{#if segmentationDocs.length > 0 || transcriptionDocs.length > 0 || readyDocs.length > 0}
<section class="mt-4 rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.mission_control_heading()}
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<SegmentationColumn
docs={segmentationDocs}
weeklyCount={weeklyStats?.segmentationCount ?? 0}
/>
<TranscriptionColumn
docs={transcriptionDocs}
weeklyCount={weeklyStats?.transcriptionCount ?? 0}
/>
<ReadyColumn docs={readyDocs} weeklyCount={weeklyStats?.readyCount ?? 0} />
</div>
</section>
{/if}

View File

@@ -56,20 +56,21 @@ 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(() => {
if (!canvasEl || !textLayerEl) return;
renderer.setElements(canvasEl, textLayerEl);
// Also track currentPage and scale so page-nav / zoom re-renders work.
// Read scale and currentPage synchronously so Svelte tracks them as dependencies.
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
renderer.renderCurrentPage().then(() => renderer.prerender());
}

View File

@@ -1,90 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
type TranscriptionQueueItemDTO = {
id: string;
title: string;
documentDate?: string;
needsExpert: boolean;
annotationCount: number;
textedBlockCount: number;
reviewedBlockCount: number;
};
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(dateStr + 'T12:00:00'));
}
function reviewedPct(doc: TranscriptionQueueItemDTO): number {
if (doc.textedBlockCount === 0) return 0;
return Math.round((doc.reviewedBlockCount / doc.textedBlockCount) * 100);
}
</script>
{#if docs.length > 0}
<div
class="flex flex-col gap-3 rounded-sm border border-brand-mint bg-brand-mint/10 p-4 transition-shadow hover:shadow-sm"
>
<div>
<div class="mb-1 flex items-center gap-2">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_ready_heading()}
</h3>
{#if weeklyCount > 0}
<span class="rounded-full bg-accent-bg px-2 py-0.5 text-xs font-semibold text-ink-2">
{m.mission_control_weekly_pulse({ count: weeklyCount })}
</span>
{/if}
</div>
<p class="text-xs font-semibold text-ink-2">
{m.mission_control_ready_subtitle({ count: docs.length })}
</p>
</div>
<ul class="space-y-1">
{#each docs as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-brand-mint/20 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span class="font-serif text-sm text-ink">{doc.title}</span>
<div class="mt-0.5 flex items-center gap-2">
{#if doc.documentDate}
<span class="text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
{/if}
{#if doc.textedBlockCount > 0}
<span class="text-xs font-semibold text-ink">
{m.mission_control_reviewed_pct({ pct: reviewedPct(doc) })}
</span>
{/if}
</div>
</a>
</li>
{/each}
</ul>
</div>
{:else}
<div
class="flex min-h-[120px] flex-col items-center justify-center rounded-sm border border-dashed border-brand-mint bg-brand-mint/5 p-6 text-center"
>
<p class="text-xs text-ink-3">{m.mission_control_ready_empty()}</p>
<a
href="/enrich?filter=NEEDS_SEGMENTATION&next=1"
class="mt-2 inline-flex items-center rounded-sm border border-ink px-3 py-2 text-xs font-semibold text-ink transition-colors hover:bg-ink hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
{m.mission_control_ready_empty_cta()}
</a>
</div>
{/if}

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import ExpertBadge from './ExpertBadge.svelte';
type TranscriptionQueueItemDTO = {
id: string;
title: string;
documentDate?: string;
needsExpert: boolean;
annotationCount: number;
textedBlockCount: number;
reviewedBlockCount: number;
};
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(dateStr + 'T12:00:00'));
}
</script>
{#if docs.length > 0}
<div class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4">
<div>
<h3 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_segmentation_heading()}
</h3>
<span
class="inline-flex items-center gap-1 rounded-full border border-line bg-accent-bg px-2 py-0.5 text-xs font-semibold text-ink"
>
{m.mission_control_seg_skill_pill()}
</span>
{#if weeklyCount > 0}
<p class="mt-1 text-xs font-semibold text-ink-2">
{m.mission_control_weekly_pulse({ count: weeklyCount })}
</p>
{/if}
</div>
<ul class="space-y-1">
{#each docs as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.needsExpert}
<ExpertBadge />
{/if}
</div>
{#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
{/if}
</a>
</li>
{/each}
</ul>
</div>
{/if}

View File

@@ -1,93 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import ExpertBadge from './ExpertBadge.svelte';
type TranscriptionQueueItemDTO = {
id: string;
title: string;
documentDate?: string;
needsExpert: boolean;
annotationCount: number;
textedBlockCount: number;
reviewedBlockCount: number;
};
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(dateStr + 'T12:00:00'));
}
function blockProgress(doc: TranscriptionQueueItemDTO): number {
if (doc.annotationCount === 0) return 0;
return (doc.textedBlockCount / doc.annotationCount) * 100;
}
</script>
{#if docs.length > 0}
<div class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4">
<div>
<h3 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_transcription_heading()}
</h3>
<span
class="inline-flex items-center gap-1 rounded-full border border-line bg-surface px-2 py-0.5 text-xs font-semibold text-ink"
>
{m.mission_control_trans_skill_pill()}
</span>
{#if weeklyCount > 0}
<p class="mt-1 text-xs font-semibold text-ink">
{m.mission_control_weekly_pulse({ count: weeklyCount })}
</p>
{/if}
</div>
<ul class="space-y-1">
{#each docs as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.needsExpert}
<ExpertBadge />
{/if}
</div>
{#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3">{formatDate(doc.documentDate)}</span>
{/if}
{#if doc.textedBlockCount > 0}
<div class="mt-1.5 flex items-center gap-2">
<span class="shrink-0 text-xs text-ink-3">
{m.mission_control_blocks_progress({
texted: doc.textedBlockCount,
total: doc.annotationCount
})}
</span>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-ink/20">
<div
class="h-full rounded-full bg-ink transition-all"
style="width: {blockProgress(doc).toFixed(0)}%"
></div>
</div>
</div>
{:else}
<span class="mt-0.5 text-xs text-ink-3 italic"></span>
{/if}
</a>
</li>
{/each}
</ul>
</div>
{/if}

View File

@@ -628,22 +628,6 @@ 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;
@@ -676,32 +660,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/transcription/segmentation-queue": {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
get: operations["getSegmentationQueue"];
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
};
"/api/transcription/transcription-queue": {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
get: operations["getTranscriptionQueue"];
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
};
"/api/transcription/ready-to-read": {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
get: operations["getReadyToRead"];
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
};
"/api/transcription/weekly-stats": {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
get: operations["getTranscriptionWeeklyStats"];
put?: never; post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never;
};
"/api/documents/{id}/needs-expert": {
parameters: { query?: never; header?: never; path: { id: string; }; cookie?: never; };
get?: never; put?: never; post?: never; delete?: never; options?: never; head?: never;
patch: operations["toggleNeedsExpert"];
trace?: never;
};
"/api/stats": {
parameters: {
query?: never;
@@ -1102,6 +1060,22 @@ 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 {
@@ -1225,7 +1199,6 @@ export interface components {
metadataComplete: boolean;
/** @enum {string} */
scriptType: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT";
needsExpert: boolean;
receivers?: components["schemas"]["Person"][];
sender?: components["schemas"]["Person"];
tags?: components["schemas"]["Tag"][];
@@ -1467,60 +1440,28 @@ 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;
/** Format: int64 */
totalDocuments?: number;
};
TranscriptionQueueItemDTO: {
/** Format: uuid */
id: string;
title: string;
/** Format: date */
documentDate?: string;
needsExpert: boolean;
/** Format: int32 */
annotationCount: number;
/** Format: int32 */
textedBlockCount: number;
/** Format: int32 */
reviewedBlockCount: number;
};
TranscriptionWeeklyStatsDTO: {
/** Format: int64 */
segmentationCount: number;
/** Format: int64 */
transcriptionCount: number;
/** Format: int64 */
readyCount: number;
};
PersonSummaryDTO: {
title?: string;
/** 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 */
@@ -1567,8 +1508,6 @@ export interface components {
/** Format: int64 */
totalElements?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -1577,6 +1516,8 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -1637,28 +1578,9 @@ export interface components {
totalPages?: number;
};
DocumentSearchResult: {
documents: components["schemas"]["Document"][];
documents?: components["schemas"]["Document"][];
/** Format: int64 */
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"][];
total?: number;
};
IncompleteDocumentDTO: {
/** Format: uuid */
@@ -3016,8 +2938,8 @@ export interface operations {
};
};
responses: {
/** @description No Content */
204: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
@@ -3073,54 +2995,6 @@ 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?: {
@@ -3165,46 +3039,6 @@ export interface operations {
};
};
};
getSegmentationQueue: {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionQueueItemDTO"][]; }; };
};
};
getTranscriptionQueue: {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionQueueItemDTO"][]; }; };
};
};
getReadyToRead: {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionQueueItemDTO"][]; }; };
};
};
getTranscriptionWeeklyStats: {
parameters: { query?: never; header?: never; path?: never; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["TranscriptionWeeklyStatsDTO"]; }; };
};
};
toggleNeedsExpert: {
parameters: { query?: never; header?: never; path: { id: string; }; cookie?: never; };
requestBody?: never;
responses: {
/** @description OK */
200: { headers: { [name: string]: unknown; }; content: { "*/*": components["schemas"]["Document"]; }; };
};
};
getStats: {
parameters: {
query?: never;
@@ -3591,7 +3425,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" | "RELEVANCE";
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE";
/** @description Sort direction: ASC or DESC */
dir?: string;
};
@@ -3768,4 +3602,25 @@ 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

@@ -1,5 +1,3 @@
import { untrack } from 'svelte';
export function createFileLoader() {
let fileUrl = $state('');
let isLoading = $state(false);
@@ -8,11 +6,7 @@ export function createFileLoader() {
async function loadFile(url: string): Promise<void> {
isLoading = true;
fileError = '';
// untrack prevents callers ($effect) from accidentally subscribing to fileUrl.
// Without it, the calling effect would re-run every time fileUrl changes (i.e.
// on every successful load), creating an infinite load loop.
const prev = untrack(() => fileUrl);
if (prev) URL.revokeObjectURL(prev);
if (fileUrl) URL.revokeObjectURL(fileUrl);
fileUrl = '';
try {

View File

@@ -1,110 +0,0 @@
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

@@ -1,46 +0,0 @@
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,9 +5,6 @@ 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'];
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
export async function load({ url, fetch }) {
const q = url.searchParams.get('q') || '';
@@ -63,14 +60,9 @@ export async function load({ url, fetch }) {
throw redirect(302, '/login');
}
const searchResult = docsResult?.data as {
documents?: Document[];
total?: number;
matchData?: Record<string, SearchMatchData>;
} | null;
const searchResult = docsResult?.data as { documents?: Document[]; total?: number } | 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;
@@ -84,28 +76,12 @@ export async function load({ url, fetch }) {
let stats: StatsDTO | null = null;
let incompleteDocs: IncompleteDocumentDTO[] = [];
let recentDocs: Document[] = [];
let segmentationDocs: TranscriptionQueueItemDTO[] = [];
let transcriptionDocs: TranscriptionQueueItemDTO[] = [];
let readyDocs: TranscriptionQueueItemDTO[] = [];
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
if (isDashboard) {
const [
statsResult,
incompleteResult,
recentResult,
segmentationResult,
transcriptionResult,
readyResult,
weeklyStatsResult
] = await Promise.allSettled([
const [statsResult, incompleteResult, recentResult] = await Promise.allSettled([
api.GET('/api/stats'),
api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }),
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } }),
api.GET('/api/transcription/segmentation-queue'),
api.GET('/api/transcription/transcription-queue'),
api.GET('/api/transcription/ready-to-read'),
api.GET('/api/transcription/weekly-stats')
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
]);
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
@@ -117,32 +93,15 @@ export async function load({ url, fetch }) {
if (recentResult.status === 'fulfilled' && recentResult.value.response.ok) {
recentDocs = recentResult.value.data ?? [];
}
if (segmentationResult.status === 'fulfilled' && segmentationResult.value.response.ok) {
segmentationDocs = (segmentationResult.value.data ?? []) as TranscriptionQueueItemDTO[];
}
if (transcriptionResult.status === 'fulfilled' && transcriptionResult.value.response.ok) {
transcriptionDocs = (transcriptionResult.value.data ?? []) as TranscriptionQueueItemDTO[];
}
if (readyResult.status === 'fulfilled' && readyResult.value.response.ok) {
readyDocs = (readyResult.value.data ?? []) as TranscriptionQueueItemDTO[];
}
if (weeklyStatsResult.status === 'fulfilled' && weeklyStatsResult.value.response.ok) {
weeklyStats = weeklyStatsResult.value.data ?? null;
}
}
return {
isDashboard,
documents,
total,
matchData,
stats,
incompleteDocs,
recentDocs,
segmentationDocs,
transcriptionDocs,
readyDocs,
weeklyStats,
initialValues: {
senderName: senderObj?.displayName ?? '',
receiverName: receiverObj?.displayName ?? ''
@@ -157,14 +116,9 @@ export async function load({ url, fetch }) {
isDashboard,
documents: [],
total: 0,
matchData: {} as Record<string, SearchMatchData>,
stats: null,
incompleteDocs: [],
recentDocs: [],
segmentationDocs: [],
transcriptionDocs: [],
readyDocs: [],
weeklyStats: null,
initialValues: { senderName: '', receiverName: '' },
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ },
error: 'Daten konnten nicht geladen werden.' as string | null

View File

@@ -9,7 +9,6 @@ import DocumentList from './DocumentList.svelte';
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
import DashboardNeedsMetadata from '$lib/components/DashboardNeedsMetadata.svelte';
import DashboardRecentDocuments from '$lib/components/DashboardRecentDocuments.svelte';
import MissionControlStrip from '$lib/components/MissionControlStrip.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -133,13 +132,6 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
</div>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
{:else}
<DocumentList
documents={data.documents ?? []}
@@ -148,7 +140,6 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
total={data.total ?? 0}
q={q}
sort={sort}
matchData={data.matchData ?? {}}
/>
{/if}
</main>

View File

@@ -4,8 +4,6 @@ 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,
@@ -13,8 +11,7 @@ let {
error,
total = 0,
q = '',
sort,
matchData = {}
sort
}: {
documents: {
id: string;
@@ -22,13 +19,8 @@ let {
originalFilename: string;
documentDate?: string | null;
location?: string | null;
sender?: {
id?: string;
firstName?: string | null;
lastName: string;
displayName: string;
} | null;
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
tags?: { id: string; name: string }[];
}[];
canWrite: boolean;
@@ -36,7 +28,6 @@ let {
total?: number;
q?: string;
sort?: string;
matchData?: Record<string, components['schemas']['SearchMatchData']>;
} = $props();
const fallbackLabel = $derived(
@@ -84,17 +75,6 @@ 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">
@@ -102,12 +82,7 @@ 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">
{#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}
{doc.title || doc.originalFilename}
</h3>
</div>
@@ -135,42 +110,6 @@ 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">
@@ -179,15 +118,7 @@ const showDividers = $derived(groupedDocuments.length >= 2);
>{m.docs_list_from()}</span
>
{#if doc.sender}
{#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}
<span class="text-ink">{doc.sender.displayName}</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
@@ -199,18 +130,7 @@ const showDividers = $derived(groupedDocuments.length >= 2);
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-ink">
{#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}
{doc.receivers.map((p) => p.displayName).join(', ')}
</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
@@ -224,18 +144,14 @@ 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 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'}"
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"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
goto(`/?tag=${encodeURIComponent(tag.name)}`);
}}
>
{#if matchedTagIds.has(tag.id)}
<span data-testid="tag-match">{tag.name}</span>
{:else}
{tag.name}
{/if}
{tag.name}
</button>
{/each}
</div>

View File

@@ -12,20 +12,14 @@ const baseProps = {
canWrite: false,
error: null,
total: 0,
q: '',
matchData: {} as Record<
string,
import('$lib/generated/api').components['schemas']['SearchMatchData']
>
q: ''
};
type DocOverrides = {
id?: string;
title?: string;
documentDate?: string | null;
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 }[];
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
};
const makeDoc = (overrides: DocOverrides = {}) => ({
@@ -36,12 +30,7 @@ const makeDoc = (overrides: DocOverrides = {}) => ({
documentDate: '2024-03-15',
location: null,
sender: null,
receivers: [] as {
id?: string;
firstName?: string | null;
lastName: string;
displayName: string;
}[],
receivers: [] as { firstName?: string | null; lastName: string; displayName: string }[],
tags: [],
...overrides
});
@@ -126,248 +115,3 @@ 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

@@ -38,8 +38,6 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
documentDate: '1923-04-12',
location: 'Berlin',
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
needsExpert: false,
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
tags: [],

View File

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