Compare commits
32 Commits
e15867e47d
...
9fb1821db5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fb1821db5 | ||
|
|
86a216918f | ||
|
|
48152517aa | ||
|
|
4af2e4ad17 | ||
|
|
94b5d1a5a8 | ||
|
|
aa8fb70d10 | ||
|
|
9404ec34ce | ||
|
|
78abc7f726 | ||
|
|
f36bebd1a8 | ||
|
|
53c5d90340 | ||
|
|
2ea603a3bf | ||
|
|
d7b2357834 | ||
|
|
eb18d4f568 | ||
|
|
091f7e5d25 | ||
|
|
32f151ff31 | ||
|
|
9ff8423da6 | ||
|
|
162397d4eb | ||
|
|
fabab6b502 | ||
|
|
bcb2898e5f | ||
|
|
2c64a6d8a4 | ||
|
|
b74ae27171 | ||
|
|
2817410f94 | ||
|
|
63d1a2e1ff | ||
|
|
bb29cac496 | ||
|
|
60dc73ba04 | ||
|
|
6cffd36b22 | ||
|
|
f723a83011 | ||
|
|
c235151075 | ||
|
|
741eebc276 | ||
|
|
8a5ca6868f | ||
|
|
a15b5ebf17 | ||
|
|
ed12a54339 |
@@ -208,8 +208,15 @@ public class DocumentController {
|
||||
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||
}
|
||||
List<Document> results = documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir);
|
||||
return ResponseEntity.ok(DocumentSearchResult.of(results));
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir));
|
||||
}
|
||||
|
||||
// --- EXPERT FLAG ---
|
||||
|
||||
@PatchMapping("/{id}/needs-expert")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Document toggleNeedsExpert(@PathVariable UUID id) {
|
||||
return documentService.toggleNeedsExpert(id);
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,35 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DocumentSearchResult(List<Document> documents, long total) {
|
||||
public record DocumentSearchResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<Document> documents,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
long total,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
Map<UUID, SearchMatchData> matchData
|
||||
) {
|
||||
/**
|
||||
* Creates a result where total equals the list size.
|
||||
* Creates a fully-enriched result from documents and their match overlay data.
|
||||
* Absent map entries (e.g. document deleted between FTS and enrichment) are safe —
|
||||
* the frontend treats a missing entry as "no match data".
|
||||
*/
|
||||
public static DocumentSearchResult withMatchData(List<Document> documents, Map<UUID, SearchMatchData> matchData) {
|
||||
return new DocumentSearchResult(documents, documents.size(), matchData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a result without match data — used for filter-only searches (no text query).
|
||||
* No pagination yet — the full matched set is always returned.
|
||||
* When pagination is added, total must come from a DB COUNT query, not list.size().
|
||||
*/
|
||||
public static DocumentSearchResult of(List<Document> documents) {
|
||||
return new DocumentSearchResult(documents, documents.size());
|
||||
return withMatchData(documents, Map.of());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* Character-level offset of a highlighted term within a text field.
|
||||
* Offsets are Java {@code String} character positions (UTF-16 code units),
|
||||
* which are identical to JavaScript string positions — consistent end-to-end
|
||||
* for all German BMP characters (ä, ö, ü, ß, etc.).
|
||||
*/
|
||||
public record MatchOffset(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int start,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int length
|
||||
) {}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Match signals for a single document in a full-text search result.
|
||||
* All fields are non-null except {@code transcriptionSnippet} and {@code summarySnippet},
|
||||
* which are null when the respective field did not match the query.
|
||||
*/
|
||||
public record SearchMatchData(
|
||||
/**
|
||||
* Best-ranked matching transcription line, or null if no block matched.
|
||||
*/
|
||||
String transcriptionSnippet,
|
||||
|
||||
/**
|
||||
* Character offsets of highlighted terms within the document title.
|
||||
* Empty when the title did not contribute to the match.
|
||||
*/
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<MatchOffset> titleOffsets,
|
||||
|
||||
/**
|
||||
* True when the sender's name matched the query.
|
||||
*/
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
boolean senderMatched,
|
||||
|
||||
/**
|
||||
* IDs of receiver persons whose names matched the query.
|
||||
*/
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<UUID> matchedReceiverIds,
|
||||
|
||||
/**
|
||||
* IDs of tags whose names matched the query.
|
||||
*/
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<UUID> matchedTagIds,
|
||||
|
||||
/**
|
||||
* Character offsets of highlighted terms within the transcription snippet.
|
||||
* Empty when no transcription block matched or the snippet has no highlights.
|
||||
*/
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<MatchOffset> snippetOffsets,
|
||||
|
||||
/**
|
||||
* Highlighted summary excerpt, or null if the summary did not match the query.
|
||||
*/
|
||||
String summarySnippet,
|
||||
|
||||
/**
|
||||
* Character offsets of highlighted terms within the summary snippet.
|
||||
* Empty when the summary did not match or has no highlights.
|
||||
*/
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<MatchOffset> summaryOffsets
|
||||
) {
|
||||
/** Canonical "no match data" value for a single document. */
|
||||
public static SearchMatchData empty() {
|
||||
return new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
) {}
|
||||
@@ -97,6 +97,11 @@ 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
|
||||
|
||||
@@ -83,10 +83,166 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
@Query(nativeQuery = true, value = """
|
||||
SELECT d.id FROM documents d
|
||||
WHERE d.search_vector @@ websearch_to_tsquery('german', :query)
|
||||
ORDER BY ts_rank(d.search_vector, websearch_to_tsquery('german', :query)) DESC,
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||
THEN to_tsquery('german', regexp_replace(
|
||||
websearch_to_tsquery('german', :query)::text,
|
||||
'''([^'']+)''',
|
||||
'''\\1'':*',
|
||||
'g'))
|
||||
END AS pq
|
||||
) q
|
||||
WHERE d.search_vector @@ q.pq
|
||||
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
|
||||
d.meta_date DESC NULLS LAST
|
||||
""")
|
||||
List<UUID> findRankedIdsByFts(@Param("query") String query);
|
||||
|
||||
/**
|
||||
* Returns match-enrichment data for a set of documents identified by their IDs.
|
||||
* Each row contains (in column order):
|
||||
* <ol>
|
||||
* <li>UUID — document id</li>
|
||||
* <li>String — title headline with \x01/\x02 delimiters around matched terms</li>
|
||||
* <li>String — best-ranked transcription snippet with \x01/\x02 delimiters, or null</li>
|
||||
* <li>Boolean — whether the sender's name matched the query</li>
|
||||
* <li>String — comma-separated matched receiver UUIDs, or null</li>
|
||||
* <li>String — comma-separated matched tag UUIDs, or null</li>
|
||||
* <li>String — summary snippet with \x01/\x02 delimiters, or null if summary didn't match</li>
|
||||
* </ol>
|
||||
* Short-circuit before calling this method when {@code ids} is empty or {@code query} is blank.
|
||||
*/
|
||||
@Query(nativeQuery = true, value = """
|
||||
SELECT
|
||||
d.id,
|
||||
ts_headline('german', d.title, q.pq,
|
||||
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',HighlightAll=true')
|
||||
AS title_headline,
|
||||
CASE WHEN best_block.text IS NOT NULL THEN
|
||||
ts_headline('german', best_block.text, q.pq,
|
||||
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',MaxWords=50,MinWords=20')
|
||||
END AS transcription_snippet,
|
||||
(s.id IS NOT NULL AND
|
||||
to_tsvector('german', COALESCE(s.first_name, '') || ' ' || COALESCE(s.last_name, ''))
|
||||
@@ q.pq)
|
||||
AS sender_matched,
|
||||
(SELECT string_agg(r.id::text, ',')
|
||||
FROM document_receivers dr
|
||||
JOIN persons r ON r.id = dr.person_id
|
||||
WHERE dr.document_id = d.id
|
||||
AND to_tsvector('german', COALESCE(r.first_name, '') || ' ' || r.last_name)
|
||||
@@ q.pq
|
||||
) AS matched_receiver_ids,
|
||||
(SELECT string_agg(t.id::text, ',')
|
||||
FROM document_tags dt
|
||||
JOIN tag t ON t.id = dt.tag_id
|
||||
WHERE dt.document_id = d.id
|
||||
AND to_tsvector('german', t.name) @@ q.pq
|
||||
) AS matched_tag_ids,
|
||||
CASE WHEN d.summary IS NOT NULL AND d.summary <> ''
|
||||
AND to_tsvector('german', d.summary) @@ q.pq
|
||||
THEN ts_headline('german', d.summary, q.pq,
|
||||
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',MaxWords=50,MinWords=20')
|
||||
END AS summary_snippet
|
||||
FROM documents d
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||
THEN to_tsquery('german', regexp_replace(
|
||||
websearch_to_tsquery('german', :query)::text,
|
||||
'''([^'']+)''',
|
||||
'''\\1'':*',
|
||||
'g'))
|
||||
END AS pq
|
||||
) q
|
||||
LEFT JOIN persons s ON s.id = d.sender_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT tb.text
|
||||
FROM transcription_blocks tb
|
||||
WHERE tb.document_id = d.id
|
||||
AND to_tsvector('german', tb.text) @@ q.pq
|
||||
ORDER BY ts_rank(to_tsvector('german', tb.text), q.pq) DESC
|
||||
LIMIT 1
|
||||
) best_block ON true
|
||||
WHERE d.id IN :ids
|
||||
""")
|
||||
List<Object[]> findEnrichmentData(@Param("ids") Collection<UUID> ids, @Param("query") String query);
|
||||
|
||||
// --- 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();
|
||||
|
||||
}
|
||||
@@ -3,10 +3,13 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.ScriptType;
|
||||
import org.raddatz.familienarchiv.model.TrainingLabel;
|
||||
@@ -290,13 +293,13 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
List<UUID> rankedIds = null;
|
||||
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||
if (rankedIds.isEmpty()) return List.of();
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
|
||||
}
|
||||
|
||||
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
|
||||
@@ -312,11 +315,13 @@ public class DocumentService {
|
||||
// generates an INNER JOIN that silently drops documents with null sender/receivers.
|
||||
if (sort == DocumentSort.RECEIVER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
return sortByFirstReceiver(results, dir);
|
||||
List<Document> sorted = sortByFirstReceiver(results, dir);
|
||||
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
|
||||
}
|
||||
if (sort == DocumentSort.SENDER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
return sortBySender(results, dir);
|
||||
List<Document> sorted = sortBySender(results, dir);
|
||||
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
|
||||
}
|
||||
|
||||
// RELEVANCE: default when text present and no explicit sort given
|
||||
@@ -325,14 +330,16 @@ public class DocumentService {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||
for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
|
||||
return results.stream()
|
||||
List<Document> sorted = results.stream()
|
||||
.sorted(Comparator.comparingInt(
|
||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text));
|
||||
}
|
||||
|
||||
Sort springSort = resolveSort(sort, dir);
|
||||
return documentRepository.findAll(spec, springSort);
|
||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
||||
return DocumentSearchResult.withMatchData(results, enrichWithMatchData(results, text));
|
||||
}
|
||||
|
||||
private Sort resolveSort(DocumentSort sort, String dir) {
|
||||
@@ -570,6 +577,13 @@ 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));
|
||||
@@ -584,6 +598,93 @@ public class DocumentService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls {@code findEnrichmentData} and converts the raw Object[] rows into a
|
||||
* {@link SearchMatchData} per document. Short-circuits when the list is empty or
|
||||
* the query is blank (no text search active).
|
||||
*/
|
||||
private Map<UUID, SearchMatchData> enrichWithMatchData(List<Document> docs, String query) {
|
||||
if (docs.isEmpty() || !StringUtils.hasText(query)) return Map.of();
|
||||
List<UUID> ids = docs.stream().map(Document::getId).toList();
|
||||
Map<UUID, SearchMatchData> result = new HashMap<>();
|
||||
for (Object[] row : documentRepository.findEnrichmentData(ids, query)) {
|
||||
UUID docId = (UUID) row[0];
|
||||
String titleHeadline = (String) row[1];
|
||||
String snippetHeadline = (String) row[2];
|
||||
Boolean senderMatched = (Boolean) row[3];
|
||||
String receiverIdsStr = (String) row[4];
|
||||
String tagIdsStr = (String) row[5];
|
||||
String summaryHeadline = (String) row[6];
|
||||
ParsedHighlight snippet = parseHighlight(snippetHeadline);
|
||||
ParsedHighlight summary = parseHighlight(summaryHeadline);
|
||||
result.put(docId, new SearchMatchData(
|
||||
snippet != null ? snippet.cleanText() : null,
|
||||
parseTitleOffsets(titleHeadline),
|
||||
senderMatched != null && senderMatched,
|
||||
parseUUIDs(receiverIdsStr),
|
||||
parseUUIDs(tagIdsStr),
|
||||
snippet != null ? snippet.offsets() : List.of(),
|
||||
summary != null ? summary.cleanText() : null,
|
||||
summary != null ? summary.offsets() : List.of()
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
|
||||
public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}
|
||||
|
||||
/**
|
||||
* Parses a {@code ts_headline} result that uses {@code chr(1)}/{@code chr(2)} as
|
||||
* start/stop delimiters. Returns the clean text (delimiters stripped) together with
|
||||
* the character offsets of each highlighted span. Returns {@code null} when
|
||||
* {@code headline} is {@code null}.
|
||||
*/
|
||||
public static ParsedHighlight parseHighlight(String headline) {
|
||||
if (headline == null) return null;
|
||||
StringBuilder clean = new StringBuilder(headline.length());
|
||||
List<MatchOffset> offsets = new ArrayList<>();
|
||||
int i = 0;
|
||||
int pos = 0; // position in the clean string (no delimiters)
|
||||
while (i < headline.length()) {
|
||||
char c = headline.charAt(i);
|
||||
if (c == '\u0001') {
|
||||
int start = pos;
|
||||
i++;
|
||||
while (i < headline.length() && headline.charAt(i) != '\u0002') {
|
||||
clean.append(headline.charAt(i));
|
||||
i++;
|
||||
pos++;
|
||||
}
|
||||
offsets.add(new MatchOffset(start, pos - start));
|
||||
i++; // skip \u0002
|
||||
} else {
|
||||
clean.append(c);
|
||||
i++;
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
return new ParsedHighlight(clean.toString(), offsets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts only the {@link MatchOffset} list from a title headline.
|
||||
* The clean title text comes from the {@link Document} entity itself.
|
||||
*/
|
||||
private static List<MatchOffset> parseTitleOffsets(String headline) {
|
||||
ParsedHighlight parsed = parseHighlight(headline);
|
||||
return parsed != null ? parsed.offsets() : List.of();
|
||||
}
|
||||
|
||||
private static List<UUID> parseUUIDs(String csv) {
|
||||
if (csv == null || csv.isBlank()) return List.of();
|
||||
return Arrays.stream(csv.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.map(UUID::fromString)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Index on transcription_blocks.document_id to speed up the LATERAL join
|
||||
-- used in DocumentService.findEnrichmentData (FTS match enrichment).
|
||||
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_id
|
||||
ON transcription_blocks (document_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -24,6 +25,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -61,7 +63,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser
|
||||
void search_returns200_whenAuthenticated() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -71,7 +73,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser
|
||||
void search_withStatusParam_passesItToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -104,7 +106,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser
|
||||
void search_responseContainsTotalCount() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -112,6 +114,28 @@ class DocumentControllerTest {
|
||||
.andExpect(jsonPath("$.documents").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseBodyContainsMatchDataKey() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder()
|
||||
.id(docId)
|
||||
.title("Brief an Anna")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData(
|
||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.matchData").isMap())
|
||||
.andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet")
|
||||
.value("Er schrieb einen langen Brief"));
|
||||
}
|
||||
|
||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DocumentSearchResultTest {
|
||||
|
||||
private Document doc(UUID id) {
|
||||
return Document.builder()
|
||||
.id(id)
|
||||
.title("Test")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withMatchData_total_equals_list_size() {
|
||||
UUID id = UUID.randomUUID();
|
||||
List<Document> docs = List.of(doc(id));
|
||||
Map<UUID, SearchMatchData> matchData = Map.of(id, SearchMatchData.empty());
|
||||
|
||||
DocumentSearchResult result = DocumentSearchResult.withMatchData(docs, matchData);
|
||||
|
||||
assertThat(result.total()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withMatchData_exposes_match_data_map() {
|
||||
UUID id = UUID.randomUUID();
|
||||
SearchMatchData data = new SearchMatchData("snippet", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
DocumentSearchResult result = DocumentSearchResult.withMatchData(List.of(doc(id)), Map.of(id, data));
|
||||
|
||||
assertThat(result.matchData()).containsKey(id);
|
||||
assertThat(result.matchData().get(id).transcriptionSnippet()).isEqualTo("snippet");
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_factory_returns_empty_match_data() {
|
||||
UUID id = UUID.randomUUID();
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(doc(id)));
|
||||
|
||||
assertThat(result.matchData()).isEmpty();
|
||||
assertThat(result.total()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void documents_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("documents").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MatchOffsetTest {
|
||||
|
||||
@Test
|
||||
void should_hold_start_and_length() {
|
||||
MatchOffset offset = new MatchOffset(6, 5);
|
||||
|
||||
assertThat(offset.start()).isEqualTo(6);
|
||||
assertThat(offset.length()).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_implement_value_equality() {
|
||||
assertThat(new MatchOffset(0, 3)).isEqualTo(new MatchOffset(0, 3));
|
||||
assertThat(new MatchOffset(0, 3)).isNotEqualTo(new MatchOffset(0, 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SearchMatchDataTest {
|
||||
|
||||
@Test
|
||||
void transcription_snippet_is_nullable() {
|
||||
SearchMatchData data = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
|
||||
assertThat(data.transcriptionSnippet()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void non_null_list_fields_are_empty_by_default_in_empty_factory() {
|
||||
SearchMatchData data = SearchMatchData.empty();
|
||||
|
||||
assertThat(data.transcriptionSnippet()).isNull();
|
||||
assertThat(data.titleOffsets()).isEmpty();
|
||||
assertThat(data.matchedReceiverIds()).isEmpty();
|
||||
assertThat(data.matchedTagIds()).isEmpty();
|
||||
assertThat(data.senderMatched()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void holds_all_field_values() {
|
||||
MatchOffset offset = new MatchOffset(0, 4);
|
||||
SearchMatchData data = new SearchMatchData(
|
||||
"schreibt dir aus dem Feld",
|
||||
List.of(offset),
|
||||
true,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThat(data.transcriptionSnippet()).isEqualTo("schreibt dir aus dem Feld");
|
||||
assertThat(data.titleOffsets()).containsExactly(offset);
|
||||
assertThat(data.senderMatched()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void snippet_offsets_are_empty_in_empty_factory() {
|
||||
SearchMatchData data = SearchMatchData.empty();
|
||||
assertThat(data.snippetOffsets()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void snippet_offsets_carry_through_constructor() {
|
||||
MatchOffset offset = new MatchOffset(5, 3);
|
||||
SearchMatchData data = new SearchMatchData(
|
||||
"Das ist ein furchtbares Bild",
|
||||
List.of(),
|
||||
false,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(offset),
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
assertThat(data.snippetOffsets()).containsExactly(offset);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,16 @@ class DocumentFtsTest {
|
||||
assertThat(ids).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_find_document_by_partial_word_prefix() {
|
||||
documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
|
||||
em.clear();
|
||||
|
||||
List<UUID> ids = documentRepository.findRankedIdsByFts("furchtb");
|
||||
|
||||
assertThat(ids).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_not_find_document_when_term_absent() {
|
||||
documentRepository.saveAndFlush(document("Familienfoto"));
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class DocumentSearchEnrichmentTest {
|
||||
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired TagRepository tagRepository;
|
||||
@Autowired AnnotationRepository annotationRepository;
|
||||
@Autowired TranscriptionBlockRepository blockRepository;
|
||||
@Autowired EntityManager em;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
blockRepository.deleteAll();
|
||||
documentRepository.deleteAll();
|
||||
personRepository.deleteAll();
|
||||
tagRepository.deleteAll();
|
||||
}
|
||||
|
||||
// ─── Lateral join: best transcription snippet ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void lateral_join_returns_highest_ranked_transcription_block() {
|
||||
Document doc = documentRepository.saveAndFlush(document("Brief an Anna"));
|
||||
UUID annotId = annotation(doc.getId());
|
||||
// Three blocks — the one with three occurrences has highest rank
|
||||
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Das Wetter war schön", 0));
|
||||
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Brief Brief Brief", 1)); // highest rank for "Brief"
|
||||
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Ein Brief liegt vor", 2)); // one occurrence
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
// row[2] is now a ts_headline result with sentinel chars — parse it for clean text
|
||||
DocumentService.ParsedHighlight parsed = DocumentService.parseHighlight((String) rows.get(0)[2]);
|
||||
assertThat(parsed).isNotNull();
|
||||
assertThat(parsed.cleanText()).isEqualTo("Brief Brief Brief");
|
||||
assertThat(parsed.offsets()).isNotEmpty(); // at least one "Brief" is highlighted
|
||||
}
|
||||
|
||||
@Test
|
||||
void document_with_no_transcription_blocks_has_null_snippet() {
|
||||
Document doc = documentRepository.saveAndFlush(document("Foto ohne Text"));
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Foto");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
Object snippet = rows.get(0)[2];
|
||||
assertThat(snippet).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void document_with_non_matching_blocks_has_null_snippet() {
|
||||
Document doc = documentRepository.saveAndFlush(document("Dok"));
|
||||
UUID annotId = annotation(doc.getId());
|
||||
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Kein Match hier", 0));
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0)[2]).isNull();
|
||||
}
|
||||
|
||||
// ─── Title headline: delimiter-based offset detection ─────────────────────
|
||||
|
||||
@Test
|
||||
void title_headline_contains_delimiters_when_title_matches() {
|
||||
Document doc = documentRepository.saveAndFlush(document("Brief an die Familie"));
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
String headline = (String) rows.get(0)[1];
|
||||
// chr(1) marks the start of the highlighted term
|
||||
assertThat(headline).contains("\u0001");
|
||||
assertThat(headline).contains("\u0002");
|
||||
}
|
||||
|
||||
@Test
|
||||
void title_headline_has_no_delimiters_when_title_does_not_match() {
|
||||
Document doc = documentRepository.saveAndFlush(document("Familienfoto"));
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
String headline = (String) rows.get(0)[1];
|
||||
assertThat(headline).doesNotContain("\u0001");
|
||||
assertThat(headline).doesNotContain("\u0002");
|
||||
}
|
||||
|
||||
@Test
|
||||
void title_headline_matches_stemmed_form() {
|
||||
// "Brief" (singular, query) should match "Briefe" (plural, in title) via German FTS stemming.
|
||||
// Both reduce to the stem "brief" under the Snowball German algorithm — verified by the
|
||||
// existing should_find_document_by_stemmed_inflected_form test in DocumentFtsTest.
|
||||
Document doc = documentRepository.saveAndFlush(document("Alte Briefe aus Berlin"));
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
String headline = (String) rows.get(0)[1];
|
||||
assertThat(headline).contains("\u0001");
|
||||
}
|
||||
|
||||
// ─── Sender match ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void sender_matched_is_true_when_sender_last_name_matches_query() {
|
||||
Person sender = personRepository.saveAndFlush(
|
||||
Person.builder().firstName("Walter").lastName("Raddatz").build());
|
||||
Document doc = documentRepository.saveAndFlush(Document.builder()
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Raddatz");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
Boolean senderMatched = (Boolean) rows.get(0)[3];
|
||||
assertThat(senderMatched).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sender_matched_is_false_when_sender_name_does_not_match() {
|
||||
Person sender = personRepository.saveAndFlush(
|
||||
Person.builder().firstName("Walter").lastName("Raddatz").build());
|
||||
Document doc = documentRepository.saveAndFlush(Document.builder()
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Schmidt");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
Boolean senderMatched = (Boolean) rows.get(0)[3];
|
||||
assertThat(senderMatched).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sender_matched_is_false_when_document_has_no_sender() {
|
||||
Document doc = documentRepository.saveAndFlush(document("Brief von unbekannt"));
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
Boolean senderMatched = (Boolean) rows.get(0)[3];
|
||||
assertThat(senderMatched).isFalse();
|
||||
}
|
||||
|
||||
// ─── Receiver match ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void matched_receiver_ids_contains_uuid_of_matching_receiver() {
|
||||
Person receiver = personRepository.saveAndFlush(
|
||||
Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||
Document doc = documentRepository.saveAndFlush(Document.builder()
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.receivers(Set.of(receiver))
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Schmidt");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
String receiverIds = (String) rows.get(0)[4];
|
||||
assertThat(receiverIds).contains(receiver.getId().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void matched_receiver_ids_is_null_when_no_receiver_matches() {
|
||||
Person receiver = personRepository.saveAndFlush(
|
||||
Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||
Document doc = documentRepository.saveAndFlush(Document.builder()
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.receivers(Set.of(receiver))
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Raddatz");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0)[4]).isNull();
|
||||
}
|
||||
|
||||
// ─── Tag match ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void matched_tag_ids_contains_uuid_of_matching_tag() {
|
||||
Tag tag = tagRepository.saveAndFlush(Tag.builder().name("Familiengeschichte").build());
|
||||
Document doc = documentRepository.saveAndFlush(Document.builder()
|
||||
.title("Dokument")
|
||||
.originalFilename("dok.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.tags(Set.of(tag))
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Familiengeschichte");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
String tagIds = (String) rows.get(0)[5];
|
||||
assertThat(tagIds).contains(tag.getId().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void matched_tag_ids_is_null_when_no_tag_matches() {
|
||||
Tag tag = tagRepository.saveAndFlush(Tag.builder().name("Familiengeschichte").build());
|
||||
Document doc = documentRepository.saveAndFlush(Document.builder()
|
||||
.title("Dokument")
|
||||
.originalFilename("dok.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.tags(Set.of(tag))
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0)[5]).isNull();
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private Document document(String title) {
|
||||
return Document.builder()
|
||||
.title(title)
|
||||
.originalFilename(title.replace(" ", "_") + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
}
|
||||
|
||||
private UUID annotation(UUID documentId) {
|
||||
DocumentAnnotation ann = annotationRepository.save(DocumentAnnotation.builder()
|
||||
.documentId(documentId)
|
||||
.pageNumber(1)
|
||||
.x(0.1).y(0.2).width(0.3).height(0.4)
|
||||
.color("#00C7B1")
|
||||
.build());
|
||||
em.flush();
|
||||
return ann.getId();
|
||||
}
|
||||
|
||||
private TranscriptionBlock block(UUID documentId, UUID annotationId, String text, int order) {
|
||||
return TranscriptionBlock.builder()
|
||||
.documentId(documentId)
|
||||
.annotationId(annotationId)
|
||||
.text(text)
|
||||
.sortOrder(order)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
@@ -51,12 +52,12 @@ class DocumentServiceSortTest {
|
||||
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(newer, older));
|
||||
|
||||
List<Document> result = documentService.searchDocuments(
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC");
|
||||
|
||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result.get(0).getId()).isEqualTo(id2); // newer doc first
|
||||
assertThat(result.documents()).hasSize(2);
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first
|
||||
}
|
||||
|
||||
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
|
||||
@@ -73,11 +74,11 @@ class DocumentServiceSortTest {
|
||||
when(documentRepository.findAll(any(Specification.class)))
|
||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||
|
||||
List<Document> result = documentService.searchDocuments(
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
|
||||
|
||||
// Expect: rank order restored (id1 first)
|
||||
assertThat(result.get(0).getId()).isEqualTo(id1);
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -92,9 +93,9 @@ class DocumentServiceSortTest {
|
||||
when(documentRepository.findAll(any(Specification.class)))
|
||||
.thenReturn(List.of(doc2, doc1));
|
||||
|
||||
List<Document> result = documentService.searchDocuments(
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result.get(0).getId()).isEqualTo(id1);
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
@@ -22,6 +25,7 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -1287,11 +1291,11 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(withSender, noSender));
|
||||
|
||||
List<Document> result = documentService.searchDocuments(
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc");
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
|
||||
assertThat(result.documents()).hasSize(2);
|
||||
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
|
||||
}
|
||||
|
||||
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
||||
@@ -1307,10 +1311,10 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(noReceivers, withReceiver));
|
||||
|
||||
List<Document> result = documentService.searchDocuments(
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc");
|
||||
|
||||
assertThat(result).extracting(Document::getTitle)
|
||||
assertThat(result.documents()).extracting(Document::getTitle)
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
}
|
||||
|
||||
@@ -1329,11 +1333,99 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(docNullName, docSmith));
|
||||
|
||||
List<Document> result = documentService.searchDocuments(
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc");
|
||||
|
||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||
assertThat(result).extracting(Document::getTitle)
|
||||
assertThat(result.documents()).extracting(Document::getTitle)
|
||||
.containsExactly("smith doc", "Null lastname doc");
|
||||
}
|
||||
|
||||
// ─── searchDocuments — match data enrichment ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchDocuments_withTextQuery_includesMatchDataWithTitleOffsets() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).title("Brief an Anna").build();
|
||||
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
|
||||
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null});
|
||||
|
||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
|
||||
|
||||
assertThat(result.matchData()).containsKey(docId);
|
||||
SearchMatchData md = result.matchData().get(docId);
|
||||
assertThat(md.titleOffsets()).hasSize(1);
|
||||
assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result.matchData()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_withTextQuery_includesTranscriptionSnippetWhenPresent() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).title("Dok").build();
|
||||
// Simulate ts_headline output with sentinel markers around the matched word
|
||||
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
|
||||
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null});
|
||||
|
||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null);
|
||||
|
||||
SearchMatchData md = result.matchData().get(docId);
|
||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||
assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
|
||||
}
|
||||
|
||||
// ─── parseHighlight unit tests ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void parseHighlight_returnsNull_whenInputIsNull() {
|
||||
assertThat(DocumentService.parseHighlight(null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseHighlight_returnsCleanTextAndEmptyOffsets_whenNoSentinels() {
|
||||
DocumentService.ParsedHighlight result = DocumentService.parseHighlight("plain text");
|
||||
assertThat(result.cleanText()).isEqualTo("plain text");
|
||||
assertThat(result.offsets()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseHighlight_extractsOffsetAndStripsDelimiters() {
|
||||
// \u0001 = start sentinel, \u0002 = stop sentinel
|
||||
DocumentService.ParsedHighlight result = DocumentService.parseHighlight("Das \u0001furchtbare\u0002 Wort");
|
||||
assertThat(result.cleanText()).isEqualTo("Das furchtbare Wort");
|
||||
assertThat(result.offsets()).containsExactly(new MatchOffset(4, 10)); // "furchtbare" at pos 4, len 10
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseHighlight_handlesMultipleHighlightedTerms() {
|
||||
DocumentService.ParsedHighlight result =
|
||||
DocumentService.parseHighlight("\u0001Hallo\u0002 und \u0001Welt\u0002");
|
||||
assertThat(result.cleanText()).isEqualTo("Hallo und Welt");
|
||||
assertThat(result.offsets()).containsExactly(
|
||||
new MatchOffset(0, 5), // "Hallo"
|
||||
new MatchOffset(10, 4) // "Welt"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1122
docs/specs/dashboard-expansion-patterns.html
Normal file
1122
docs/specs/dashboard-expansion-patterns.html
Normal file
File diff suppressed because it is too large
Load Diff
814
docs/specs/mission-control-strip-final.html
Normal file
814
docs/specs/mission-control-strip-final.html
Normal file
@@ -0,0 +1,814 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Mission-Control-Streifen — Finale Spec (Issue #240)</title>
|
||||
<style>
|
||||
:root{
|
||||
--navy:#002850;--mint:#A6DAD8;--sand:#E4E2D7;
|
||||
--surface:#FAFAF7;--bg:#E8E7E2;--border:#D8D7D0;
|
||||
--text:#1C1C18;--muted:#6B6A63;--subtle:#9B9A93;
|
||||
--orange:#C26A00;--orange-bg:#FEF4E2;
|
||||
--green:#2E6E39;--green-bg:#EAF5EA;
|
||||
--purple:#5B5EA6;--purple-bg:#EEEDFE;
|
||||
--font:system-ui,sans-serif;--mono:'Courier New',monospace;
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1100px;margin:0 auto;padding:48px 32px 96px;}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:48px 0;}
|
||||
|
||||
/* Header */
|
||||
.hdr{background:var(--navy);color:#fff;padding:32px 32px 28px;border-radius:8px 8px 0 0;}
|
||||
.hdr h1{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;}
|
||||
.hdr-meta{font-family:var(--mono);font-size:11px;color:rgba(255,255,255,.45);margin-top:10px;}
|
||||
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.05em;background:var(--mint);color:var(--navy);}
|
||||
.badge-g{background:rgba(255,255,255,.15);color:rgba(255,255,255,.9);}
|
||||
.badges{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
|
||||
.decision-box{background:#fff;border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;padding:20px 28px 24px;margin-bottom:40px;}
|
||||
.decision-box h2{font-family:Georgia,serif;font-size:16px;font-weight:400;color:var(--navy);margin-bottom:8px;}
|
||||
.prose{font-size:13px;color:var(--muted);line-height:1.65;max-width:720px;margin-bottom:10px;}
|
||||
.prose:last-child{margin-bottom:0;}
|
||||
|
||||
/* Sections */
|
||||
.sec{margin-bottom:52px;}
|
||||
.sec-label{font-size:10px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:22px;}
|
||||
.sec-title{font-family:Georgia,serif;font-size:20px;font-weight:400;color:var(--navy);margin-bottom:4px;}
|
||||
.sec-sub{font-size:13px;color:var(--muted);margin-bottom:16px;}
|
||||
|
||||
/* Tags */
|
||||
.tag-list{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.04em;}
|
||||
.t-g{background:var(--green-bg);color:var(--green);}
|
||||
.t-o{background:var(--orange-bg);color:var(--orange);}
|
||||
.t-n{background:rgba(0,40,80,.08);color:var(--navy);}
|
||||
.t-p{background:var(--purple-bg);color:var(--purple);}
|
||||
|
||||
/* Pipeline diagram */
|
||||
.pipeline{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:14px 18px;background:#fff;border:1px solid var(--border);border-radius:6px;margin-bottom:24px;}
|
||||
.pipe-node{text-align:center;}
|
||||
.pipe-badge{display:inline-block;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;margin-bottom:4px;}
|
||||
.pipe-badge.n1{background:rgba(0,40,80,.08);color:var(--navy);}
|
||||
.pipe-badge.n2{background:rgba(0,40,80,.08);color:var(--navy);}
|
||||
.pipe-badge.n3{background:rgba(0,40,80,.08);color:var(--navy);}
|
||||
.pipe-badge.done{background:var(--green-bg);color:var(--green);}
|
||||
.pipe-sub{font-size:10px;color:var(--muted);}
|
||||
.pipe-arrow{font-size:16px;color:var(--border);flex-shrink:0;}
|
||||
.pipe-col-label{font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-top:4px;}
|
||||
.pipe-col-label.s{color:var(--navy);}
|
||||
.pipe-col-label.t{color:var(--navy);}
|
||||
.pipe-col-label.l{color:var(--green);}
|
||||
|
||||
/* Column definition grid */
|
||||
.col-defs{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:28px;}
|
||||
.col-def{background:#fff;border:1px solid var(--border);border-radius:6px;padding:14px;}
|
||||
.col-def-title{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-bottom:6px;}
|
||||
.col-def-title.n{color:var(--navy);}
|
||||
.col-def-title.g{color:var(--green);}
|
||||
.col-def p{font-size:12px;color:var(--muted);line-height:1.5;margin-bottom:8px;}
|
||||
.col-def code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
|
||||
|
||||
/* Callout */
|
||||
.callout{display:flex;gap:12px;padding:14px 16px;border-radius:4px;margin-bottom:16px;font-size:12px;line-height:1.55;}
|
||||
.callout.orange{background:var(--orange-bg);border-left:3px solid var(--orange);}
|
||||
.callout.green{background:var(--green-bg);border-left:3px solid var(--green);}
|
||||
.callout.navy{background:rgba(0,40,80,.05);border-left:3px solid var(--navy);}
|
||||
.callout.purple{background:var(--purple-bg);border-left:3px solid var(--purple);}
|
||||
.callout strong{font-weight:700;}
|
||||
.callout strong.o{color:var(--orange);}
|
||||
.callout strong.g{color:var(--green);}
|
||||
.callout strong.n{color:var(--navy);}
|
||||
.callout strong.p{color:var(--purple);}
|
||||
|
||||
/* Sorting options */
|
||||
.sort-options{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:20px;}
|
||||
.sort-opt{background:#fff;border:1px solid var(--border);border-radius:6px;padding:14px;position:relative;}
|
||||
.sort-opt.rec{border-color:var(--navy);box-shadow:0 0 0 1px var(--navy);}
|
||||
.sort-opt-rec-badge{position:absolute;top:-8px;right:10px;background:var(--navy);color:#fff;font-size:9px;font-weight:700;padding:2px 8px;border-radius:4px;letter-spacing:.05em;}
|
||||
.sort-opt h4{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:6px;}
|
||||
.sort-opt p{font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:8px;}
|
||||
.sort-opt code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;display:block;margin-top:6px;line-height:1.6;}
|
||||
|
||||
/* Frames */
|
||||
.frames-row{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;margin-bottom:16px;}
|
||||
.caption{font-family:var(--mono);font-size:10px;color:var(--muted);display:block;margin-top:6px;}
|
||||
|
||||
/* Desktop frame */
|
||||
.frame-desktop{background:var(--surface);border-radius:8px;overflow:hidden;border:1px solid var(--border);box-shadow:0 4px 16px rgba(0,0,0,.08);}
|
||||
.f-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 8px;gap:5px;}
|
||||
.f-logo{font-size:6.5px;font-weight:700;color:#fff;letter-spacing:.7px;border-bottom:1px solid var(--mint);padding-bottom:1px;}
|
||||
.f-navlinks{display:flex;gap:5px;margin-left:8px;}
|
||||
.f-navlink{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:600;text-transform:uppercase;}
|
||||
.f-navlink.on{color:rgba(255,255,255,.9);}
|
||||
.f-navr{margin-left:auto;}
|
||||
.f-av{width:14px;height:14px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.f-body{padding:10px;}
|
||||
.f-search{background:#fff;border:1px solid var(--border);border-radius:4px;height:24px;display:flex;align-items:center;padding:0 8px;gap:5px;margin-bottom:5px;}
|
||||
.f-si{font-size:9px;color:var(--muted);}
|
||||
.f-st{font-size:7.5px;color:var(--subtle);flex:1;}
|
||||
.f-resume{background:var(--mint);opacity:.2;height:7px;border-radius:3px;margin-bottom:8px;}
|
||||
.f-grid-2{display:grid;grid-template-columns:1fr 155px;gap:7px;margin-bottom:7px;}
|
||||
.f-grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;}
|
||||
.f-card{background:#fff;border:1px solid var(--sand);border-radius:3px;padding:7px;}
|
||||
.f-ht{font-size:6px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:5px;}
|
||||
.f-ht.o{color:var(--orange);}
|
||||
.f-ht.g{color:var(--green);}
|
||||
.f-ht.n{color:var(--navy);}
|
||||
.f-row{border-bottom:1px solid var(--sand);padding:3px 0;}
|
||||
.f-row:last-of-type{border-bottom:none;}
|
||||
.f-dn{font-family:Georgia,serif;font-size:7.5px;color:var(--navy);line-height:1.3;}
|
||||
.f-ds{font-size:6px;color:var(--muted);margin-top:1px;}
|
||||
.f-dd{font-size:5.5px;color:var(--subtle);margin-left:auto;white-space:nowrap;flex-shrink:0;padding-top:1px;}
|
||||
.f-lnk{font-size:6px;color:var(--navy);display:block;margin-top:5px;}
|
||||
.f-lnk.g{color:var(--green);}
|
||||
.f-stat{font-size:5.5px;color:var(--muted);margin-top:5px;}
|
||||
.f-dz{border:1.5px dashed var(--mint);background:rgba(166,218,216,.07);border-radius:3px;padding:7px;text-align:center;}
|
||||
.f-dz-i{font-size:12px;color:var(--navy);opacity:.35;margin-bottom:2px;}
|
||||
.f-dz-t{font-size:6px;font-weight:700;color:var(--navy);}
|
||||
.f-dz-s{font-size:5px;color:var(--muted);}
|
||||
.rhs{display:flex;flex-direction:column;gap:6px;}
|
||||
|
||||
/* Strip columns */
|
||||
.strip-col{border-radius:3px;padding:6px;display:flex;flex-direction:column;gap:4px;}
|
||||
.strip-col.seg{background:rgba(0,40,80,.03);border:1px solid var(--sand);}
|
||||
.strip-col.trans{background:rgba(0,40,80,.03);border:1px solid var(--sand);}
|
||||
.strip-col.done{background:rgba(166,218,216,.10);border:1px solid var(--mint);}
|
||||
.strip-col.done-empty{background:rgba(166,218,216,.06);border:1.5px dashed var(--mint);align-items:center;justify-content:center;text-align:center;min-height:100px;}
|
||||
|
||||
/* Skill pill */
|
||||
.skill-pill{display:inline-flex;align-items:center;padding:1px 5px;border-radius:8px;font-size:5px;font-weight:700;margin-bottom:3px;}
|
||||
.skill-pill.easy{background:var(--green-bg);border:1px solid rgba(46,110,57,.2);color:var(--green);}
|
||||
.skill-pill.kurrent{background:rgba(0,40,80,.08);border:1px solid rgba(0,40,80,.15);color:var(--navy);}
|
||||
|
||||
/* Pulse */
|
||||
.pulse{display:flex;align-items:center;gap:4px;margin-bottom:3px;}
|
||||
.pulse-num{font-size:5.5px;font-weight:700;}
|
||||
.pulse-num.g{color:var(--green);}
|
||||
.pulse-num.n{color:var(--navy);}
|
||||
.pulse-open{font-size:5px;color:var(--muted);}
|
||||
|
||||
/* Avatars */
|
||||
.avatars{display:flex;gap:2px;margin-bottom:4px;}
|
||||
.av-sm{width:10px;height:10px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:700;color:#fff;}
|
||||
.av-more{font-size:5px;color:var(--muted);line-height:10px;margin-left:2px;}
|
||||
|
||||
/* Per-doc bar */
|
||||
.doc-bar-row{display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--sand);padding-bottom:4px;}
|
||||
.doc-bar-row:last-child{border-bottom:none;}
|
||||
.bar-track{flex:1;height:3px;background:rgba(0,40,80,.12);border-radius:2px;overflow:hidden;}
|
||||
.bar-fill{height:100%;background:var(--navy);border-radius:2px;}
|
||||
.bar-label{font-size:5px;color:var(--muted);white-space:nowrap;}
|
||||
|
||||
/* CTA button */
|
||||
.cta-btn{display:block;font-size:6px;font-weight:700;color:#fff;background:var(--navy);border-radius:2px;padding:3px 6px;text-align:center;margin-top:3px;}
|
||||
.cta-btn.ghost{background:transparent;color:var(--navy);border:1px solid var(--navy);}
|
||||
|
||||
/* Expert badge */
|
||||
.expert-badge{display:inline-flex;align-items:center;gap:2px;padding:1px 4px;border-radius:3px;font-size:5px;font-weight:700;background:var(--purple-bg);color:var(--purple);border:1px solid rgba(91,94,166,.2);margin-left:3px;}
|
||||
|
||||
/* Phone frame */
|
||||
.frame-phone{width:200px;flex-shrink:0;background:var(--surface);border-radius:24px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.12),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;border:4px solid #1C1C18;}
|
||||
.ph-nav{height:20px;background:var(--navy);display:flex;align-items:center;padding:0 6px;}
|
||||
.ph-logo{font-size:5.5px;font-weight:700;color:#fff;letter-spacing:.6px;border-bottom:1px solid var(--mint);padding-bottom:1px;}
|
||||
.ph-body{flex:1;overflow:hidden;padding:6px;display:flex;flex-direction:column;gap:4px;}
|
||||
.ph-search{background:#fff;border:1px solid var(--border);border-radius:3px;height:18px;display:flex;align-items:center;padding:0 6px;}
|
||||
.ph-st{font-size:6.5px;color:var(--subtle);flex:1;}
|
||||
|
||||
/* impl-ref */
|
||||
.impl-ref{margin-top:20px;}
|
||||
.impl-ref table{width:100%;border-collapse:collapse;font-size:12px;}
|
||||
.impl-ref th{background:var(--navy);color:#fff;padding:6px 10px;text-align:left;font-size:10px;font-weight:600;letter-spacing:.06em;}
|
||||
.impl-ref td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;}
|
||||
.impl-ref tr:nth-child(even) td{background:var(--surface);}
|
||||
.impl-ref code{font-family:var(--mono);font-size:11px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
|
||||
|
||||
/* Component list */
|
||||
.comp-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;}
|
||||
.comp-card{background:#fff;border:1px solid var(--border);border-radius:6px;padding:14px;}
|
||||
.comp-card h4{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:4px;}
|
||||
.comp-card p{font-size:11px;color:var(--muted);line-height:1.5;}
|
||||
.comp-card code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ── HEADER ───────────────────────────────────────────────────────── -->
|
||||
<div class="hdr">
|
||||
<h1>Mission-Control-Streifen — Finale Spec</h1>
|
||||
<div class="badges">
|
||||
<span class="badge">Issue #240</span>
|
||||
<span class="badge badge-g">Leonie Voss — UX & Accessibility</span>
|
||||
<span class="badge badge-g">15. April 2026</span>
|
||||
<span class="badge badge-g">v3 — Final</span>
|
||||
</div>
|
||||
<div class="hdr-meta">src/routes/+page.svelte · src/lib/components/DashboardMissionControl.svelte · +page.server.ts</div>
|
||||
</div>
|
||||
<div class="decision-box">
|
||||
<h2>Entscheidung</h2>
|
||||
<p class="prose">
|
||||
Der bestehende Dashboard-Aufbau (Neueste Aktivität links, DropZone + Metadaten-Widget rechts) bleibt unverändert.
|
||||
Unterhalb des Zwei-Spalten-Gitters erscheint ein neuer vollbreiter <strong>Mission-Control-Streifen</strong> mit drei
|
||||
gleichwertigen Spalten: <em>Rahmen einzeichnen</em> (Segmentierung, kein Vorwissen nötig),
|
||||
<em>Text eintippen</em> (Transkription, Kurrent hilfreich), <em>Lesefertig ✓</em> (Belohnungsbereich).
|
||||
</p>
|
||||
<p class="prose">
|
||||
Die „Transkription fehlt"-Spalte aus Issue #240 wird in Segmentierung + Transkription aufgeteilt, um
|
||||
eine klare Beitragspyramide zu schaffen: Jeder kann Rahmen einzeichnen — nicht jeder kann Kurrent lesen.
|
||||
Ein wöchentlich rotierender Sort mit <em>Experten-gesucht</em>-Escape-Hatch verhindert, dass schwer lesbare
|
||||
Dokumente dauerhaft die Spalte blockieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ── PIPELINE ─────────────────────────────────────────────────────── -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Dokument-Lebenszyklus</div>
|
||||
<div class="pipeline">
|
||||
<div class="pipe-node">
|
||||
<div class="pipe-badge n1">Kein Segment</div>
|
||||
<div class="pipe-sub">0 Annotationen</div>
|
||||
<div class="pipe-col-label s">→ Spalte 1</div>
|
||||
</div>
|
||||
<div class="pipe-arrow">→</div>
|
||||
<div class="pipe-node">
|
||||
<div class="pipe-badge n2">Segmentiert</div>
|
||||
<div class="pipe-sub">Rahmen da, wenig Text</div>
|
||||
<div class="pipe-col-label t">→ Spalte 2</div>
|
||||
</div>
|
||||
<div class="pipe-arrow">→</div>
|
||||
<div class="pipe-node">
|
||||
<div class="pipe-badge n3">In Review</div>
|
||||
<div class="pipe-sub">Text da, reviewed < 90 %</div>
|
||||
<div class="pipe-col-label t">→ Spalte 2</div>
|
||||
</div>
|
||||
<div class="pipe-arrow">→</div>
|
||||
<div class="pipe-node">
|
||||
<div class="pipe-badge done">Lesefertig ✓</div>
|
||||
<div class="pipe-sub">reviewed ≥ 90 %</div>
|
||||
<div class="pipe-col-label l">→ Spalte 3</div>
|
||||
</div>
|
||||
<div style="margin-left:auto;font-size:11px;color:var(--muted);max-width:200px;line-height:1.4;">
|
||||
„Segmentiert" und „In Review" landen beide in Spalte 2 —
|
||||
unterschieden durch den per-Dokument-Balken (0 Blöcke vs. N Blöcke).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column definitions -->
|
||||
<div class="col-defs">
|
||||
<div class="col-def">
|
||||
<div class="col-def-title n">Spalte 1 — Rahmen einzeichnen</div>
|
||||
<p>Dokumente ohne Annotationsrahmen. Kein Kurrent nötig — Textblöcke markieren reicht.</p>
|
||||
<p><strong>Bedingung:</strong> <code>annotation_count = 0</code></p>
|
||||
<p><strong>Sort:</strong> Wöchentliche Rotation (seeded shuffle, s. u.)</p>
|
||||
<p><strong>Fortschritt:</strong> Wochenpuls „↑ +5 diese Woche", kein globaler Balken</p>
|
||||
</div>
|
||||
<div class="col-def">
|
||||
<div class="col-def-title n">Spalte 2 — Text eintippen</div>
|
||||
<p>Annotationen vorhanden, aber Text fehlt oder reviewed < 90 %. Kurrent-Kenntnisse hilfreich.</p>
|
||||
<p><strong>Bedingung:</strong> <code>annotation_count > 0 AND reviewed_pct < 0.90</code></p>
|
||||
<p><strong>Sort:</strong> Teilfortschritt zuerst, dann wöchentliche Rotation; <code>needsExpert</code>-Flagge schiebt nach hinten</p>
|
||||
<p><strong>Fortschritt:</strong> Per-Dokument-Balken „3 / 8 Blöcke"</p>
|
||||
</div>
|
||||
<div class="col-def" style="background:rgba(166,218,216,.06);border-color:var(--mint);">
|
||||
<div class="col-def-title g">Spalte 3 — Lesefertig ✓</div>
|
||||
<p>Reviewed ≥ 90 %. Keine Aufgabe — Einladung zum Lesen.</p>
|
||||
<p><strong>Bedingung:</strong> <code>reviewed_pct >= 0.90</code></p>
|
||||
<p><strong>Sort:</strong> Neueste zuerst</p>
|
||||
<p><strong>Fortschritt:</strong> „94 % geprüft" als Text — kein Balken, die mint-Spalte ist das Signal</p>
|
||||
<p><strong>Leerstand:</strong> Cross-Column-Redirect zu Spalte 1</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ── HARD DOCUMENTS PROBLEM ─────────────────────────────────────────── -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Sortierstrategie — Das „zu schwer"-Problem</div>
|
||||
<div class="sec-title">Schwer lesbare Dokumente blockieren die Spalte</div>
|
||||
<div class="sec-sub">Wenn dieselben 3 Dokumente immer oben stehen und niemand sie lesen kann, stoppt die Transkription komplett.</div>
|
||||
|
||||
<div class="callout orange">
|
||||
<div><strong class="o">Problem:</strong> Bei 1 500 Dokumenten ohne Transkription und sortiert nach <code>updated_at</code>
|
||||
können dieselben 3 besonders schwer lesbaren Dokumente dauerhaft die Spalte blockieren.
|
||||
Jeder öffnet sie, gibt auf, und die Spalte wird zur Sackgasse.</div>
|
||||
</div>
|
||||
|
||||
<div class="sort-options">
|
||||
<!-- Option 1 -->
|
||||
<div class="sort-opt">
|
||||
<h4>Option 1 — Zufällig pro Seitenaufruf</h4>
|
||||
<p><code>ORDER BY RANDOM()</code></p>
|
||||
<p>Jeder Besuch zeigt andere Dokumente. Kein Aufwand, aber chaotisch — kein Nutzer sieht ein Dokument zweimal,
|
||||
kann nicht gezielt zurückkehren.</p>
|
||||
<div class="tag-list"><span class="tag t-g">+ Null Aufwand</span><span class="tag t-o">− Chaotisch</span><span class="tag t-o">− Kein stabiles Lesezeichen</span></div>
|
||||
</div>
|
||||
<!-- Option 2 — RECOMMENDED -->
|
||||
<div class="sort-opt rec">
|
||||
<div class="sort-opt-rec-badge">★ Empfohlen</div>
|
||||
<h4>Option 2 — Teilfortschritt + wöchentliche Rotation</h4>
|
||||
<p>Dokumente mit Teilfortschritt (3/8 Blöcke) erscheinen zuerst — am ehesten abschließbar. Dokumente mit 0 Blöcken rotieren wöchentlich durch einen deterministischen Shuffle.</p>
|
||||
<code>ORDER BY textedBlocks DESC,
|
||||
HASHTEXT(id || EXTRACT(WEEK FROM NOW())::text)</code>
|
||||
<div class="tag-list" style="margin-top:8px;"><span class="tag t-g">+ Konsistent innerhalb einer Woche</span><span class="tag t-g">+ Bringt leichte Dokumente an die Oberfläche</span><span class="tag t-g">+ Kein neues Datenbankfeld</span></div>
|
||||
</div>
|
||||
<!-- Option 3 -->
|
||||
<div class="sort-opt">
|
||||
<h4>Option 3 — Manuelle Schwierigkeitsbewertung</h4>
|
||||
<p>Beitragende bewerten Dokumente 1–3 nach Versuch. Einfache Dokumente erscheinen zuerst.</p>
|
||||
<p>Beste Langzeitlösung — braucht aber Bewertungs-UI auf der Enrich-Seite und Signalakkumulation.</p>
|
||||
<div class="tag-list"><span class="tag t-g">+ Selbstverbessernd</span><span class="tag t-o">− UI-Aufwand</span><span class="tag t-o">− Braucht Zeit bis Signal</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Escape hatch -->
|
||||
<div class="callout navy">
|
||||
<div>
|
||||
<strong class="n">Escape-Hatch: „Experten gesucht"-Flagge (Option 2 ergänzen)</strong><br/>
|
||||
Im Enrich-Bereich: ein einzelner Button „Zu schwer — Hilfe gesucht".
|
||||
Setzt <code>Document.needsExpert = true</code> (1 Boolean, keine Migration wenn Flyway-Migration V{n} hinzugefügt wird).
|
||||
In der Transkriptions-Spalte zeigen flagged Dokumente einen lila Badge und werden hinter unflagged Dokumenten einsortiert.
|
||||
Kein Leaderboard, keine Scham — nur ein ehrliches Signal an die Community.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expert badge mockup -->
|
||||
<div style="background:#fff;border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:16px;">
|
||||
<div style="font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:10px;">Mockup: Experten-gesucht-Badge in der Transkriptions-Zeile</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;max-width:380px;">
|
||||
<!-- Normal doc -->
|
||||
<div style="display:flex;flex-direction:column;gap:3px;padding:8px;border:1px solid var(--sand);border-radius:3px;">
|
||||
<div style="font-family:Georgia,serif;font-size:13px;color:var(--navy);">Reisepass Opa Heinrich <span style="font-family:system-ui;font-size:10px;font-weight:600;background:rgba(0,40,80,.07);color:var(--navy);padding:1px 6px;border-radius:4px;">3 / 8 Blöcke</span></div>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<div style="flex:1;height:4px;background:rgba(0,40,80,.12);border-radius:2px;overflow:hidden;"><div style="width:37%;height:100%;background:var(--navy);border-radius:2px;"></div></div>
|
||||
<div style="font-size:11px;color:var(--muted);">37 %</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expert-needed doc -->
|
||||
<div style="display:flex;flex-direction:column;gap:3px;padding:8px;border:1px solid rgba(91,94,166,.25);background:rgba(91,94,166,.03);border-radius:3px;">
|
||||
<div style="font-family:Georgia,serif;font-size:13px;color:var(--navy);">Standesamt Breslau 1872
|
||||
<span style="font-family:system-ui;font-size:10px;font-weight:600;background:var(--purple-bg);color:var(--purple);padding:1px 6px;border-radius:4px;border:1px solid rgba(91,94,166,.2);">Experten gesucht</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--muted);">Schrift besonders schwer lesbar — Hilfe willkommen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>SQL / Tailwind</th><th>Wert</th><th>Hinweis</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Sort Transkription</td><td><code>ORDER BY textedBlocks DESC, HASHTEXT(id::text || EXTRACT(WEEK FROM NOW())::int::text)</code></td><td>—</td><td>Kein neues Feld nötig; ändert sich automatisch jede Woche</td></tr>
|
||||
<tr><td><code>needsExpert</code>-Flag</td><td><code>ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE</code></td><td>Flyway <code>V{n}__add_needs_expert.sql</code></td><td>Flagged Docs ans Ende: <code>ORDER BY needs_expert ASC, ...</code></td></tr>
|
||||
<tr><td>Experten-Badge</td><td><code>inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-purple-50 border border-purple-200 text-purple-700</code></td><td>Kontrast 6,8:1 ✓</td><td>Nur wenn <code>doc.needsExpert === true</code></td></tr>
|
||||
<tr><td>„Zu schwer"-Button (Enrich)</td><td><code>text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2</code></td><td>—</td><td>Unscheinbar — kein roter Knopf, keine Scham</td></tr>
|
||||
<tr><td>Endpoint (Flagge setzen)</td><td><code>PATCH /api/documents/{id}/needs-expert</code></td><td><code>@RequirePermission(READ_ALL)</code></td><td>Jeder angemeldete Nutzer darf flaggen</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ── DESKTOP MOCKUP — FILLED STATE ─────────────────────────────────── -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Mockup — Desktop, normaler Zustand</div>
|
||||
|
||||
<div class="frames-row">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="frame-desktop">
|
||||
<div class="f-nav">
|
||||
<div class="f-logo">FAMILIENARCHIV</div>
|
||||
<div class="f-navlinks"><div class="f-navlink on">Archiv</div><div class="f-navlink">Personen</div><div class="f-navlink">Gespräche</div></div>
|
||||
<div class="f-navr"><div class="f-av">MR</div></div>
|
||||
</div>
|
||||
<div class="f-body">
|
||||
<div class="f-search"><div class="f-si">⌕</div><div class="f-st">Dokumente durchsuchen…</div></div>
|
||||
<div class="f-resume"></div>
|
||||
|
||||
<!-- Existing grid — unchanged -->
|
||||
<div class="f-grid-2">
|
||||
<div class="f-card">
|
||||
<div class="f-ht">Neueste Aktivität</div>
|
||||
<div class="f-row" style="display:flex;"><div><div class="f-dn">Brief von Oma Martha, 1943</div><div class="f-ds">Karl Raddatz</div></div><div class="f-dd">12. Apr</div></div>
|
||||
<div class="f-row" style="display:flex;"><div><div class="f-dn">Taufurkunde Karl Raddatz</div><div class="f-ds">Standesamt</div></div><div class="f-dd">9. Apr</div></div>
|
||||
<div class="f-row" style="display:flex;"><div><div class="f-dn">Postkarte aus Breslau</div><div class="f-ds">Martha Raddatz</div></div><div class="f-dd">7. Apr</div></div>
|
||||
<div class="f-row" style="display:flex;"><div><div class="f-dn">Familienfoto Sommer 1952</div><div class="f-ds">Unbekannt</div></div><div class="f-dd">3. Apr</div></div>
|
||||
<div class="f-stat">47 Dokumente · 12 Personen</div>
|
||||
</div>
|
||||
<div class="rhs">
|
||||
<div class="f-dz"><div class="f-dz-i">↑</div><div class="f-dz-t">Datei hochladen</div><div class="f-dz-s">Drag & Drop</div></div>
|
||||
<div class="f-card" style="flex:1;">
|
||||
<div class="f-ht o">Metadaten fehlen</div>
|
||||
<div class="f-row"><div class="f-dn">Familienfoto 1952</div><div class="f-ds">Titel fehlt</div></div>
|
||||
<div class="f-row"><div class="f-dn">Standesamtsurkunde</div><div class="f-ds">Datum fehlt</div></div>
|
||||
<a class="f-lnk">Alle 5 anzeigen →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ★ Mission Control Strip -->
|
||||
<div style="background:#fff;border:1px solid var(--sand);border-radius:3px;padding:8px;">
|
||||
<div class="f-ht" style="margin-bottom:7px;">Was braucht Aufmerksamkeit?</div>
|
||||
<div class="f-grid-3">
|
||||
|
||||
<!-- Col 1: SEGMENTIERUNG -->
|
||||
<div class="strip-col seg">
|
||||
<div>
|
||||
<div class="f-ht n" style="margin-bottom:2px;">Rahmen einzeichnen</div>
|
||||
<div class="skill-pill easy">✓ Ohne Vorkenntnisse</div>
|
||||
<div class="pulse"><span class="pulse-num g">↑ +5 diese Woche</span><span class="pulse-open">· 1 480 offen</span></div>
|
||||
<div class="avatars">
|
||||
<div class="av-sm" style="background:var(--navy);">MR</div>
|
||||
<div class="av-sm" style="background:var(--purple);">TG</div>
|
||||
<div class="av-sm" style="background:#8C6E3F;">AS</div>
|
||||
<div class="av-more">+ 2</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div><div class="f-ds">Noch keine Rahmen</div></div>
|
||||
<div class="f-row"><div class="f-dn">Standesamt 1889</div><div class="f-ds">Noch keine Rahmen</div></div>
|
||||
<div class="f-row"><div class="f-dn">Heiratsurkunde 1921</div><div class="f-ds">Noch keine Rahmen</div></div>
|
||||
<a class="cta-btn">Jetzt einzeichnen →</a>
|
||||
</div>
|
||||
|
||||
<!-- Col 2: TRANSKRIPTION with per-doc bar + expert badge -->
|
||||
<div class="strip-col trans">
|
||||
<div>
|
||||
<div class="f-ht n" style="margin-bottom:2px;">Text eintippen</div>
|
||||
<div class="skill-pill kurrent">Kurrent hilfreich</div>
|
||||
<div class="pulse"><span class="pulse-num n">↑ +2 diese Woche</span><span class="pulse-open">· 8 offen</span></div>
|
||||
<div class="avatars">
|
||||
<div class="av-sm" style="background:var(--navy);">MR</div>
|
||||
<div class="av-more">1 Person</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Per-document bar — partial progress first -->
|
||||
<div class="doc-bar-row">
|
||||
<div class="f-dn">Reisepass Opa Heinrich</div>
|
||||
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:37%;"></div></div><div class="bar-label">3 / 8 Blöcke</div></div>
|
||||
</div>
|
||||
<div class="doc-bar-row">
|
||||
<div class="f-dn">Brief v. Oma Martha 1943</div>
|
||||
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 6 Blöcke</div></div>
|
||||
</div>
|
||||
<!-- Expert-needed doc — sorted last -->
|
||||
<div class="doc-bar-row" style="border-color:rgba(91,94,166,.2);background:rgba(91,94,166,.03);padding:2px 3px;">
|
||||
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;"><div class="f-dn">Standesamt Breslau 1872</div><span class="expert-badge">Experten gesucht</span></div>
|
||||
<div class="f-ds">Schrift besonders schwer lesbar</div>
|
||||
</div>
|
||||
<a class="cta-btn">Jetzt tippen →</a>
|
||||
</div>
|
||||
|
||||
<!-- Col 3: LESEFERTIG — filled -->
|
||||
<div class="strip-col done">
|
||||
<div>
|
||||
<div class="f-ht g" style="margin-bottom:2px;">Lesefertig ✓</div>
|
||||
<div style="font-size:5.5px;color:var(--green);font-weight:600;margin-bottom:4px;">3 Dokumente bereit</div>
|
||||
<div class="avatars">
|
||||
<div class="av-sm" style="background:var(--green);">MR</div>
|
||||
<div class="av-sm" style="background:var(--purple);">TG</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);">
|
||||
<div class="f-dn">Postkarte aus Breslau 1943</div>
|
||||
<div style="font-size:5.5px;color:var(--green);font-weight:600;">100 % geprüft</div>
|
||||
</div>
|
||||
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);">
|
||||
<div class="f-dn">Brief Oma Martha 1938</div>
|
||||
<div style="font-size:5.5px;color:var(--green);font-weight:600;">95 % geprüft</div>
|
||||
</div>
|
||||
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);">
|
||||
<div class="f-dn">Heiratsurkunde 1921</div>
|
||||
<div style="font-size:5.5px;color:var(--green);font-weight:600;">91 % geprüft</div>
|
||||
</div>
|
||||
<a class="f-lnk g" style="margin-top:3px;">Alle 3 lesen →</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="caption">Desktop (55 %) — normaler Zustand: Teilfortschritt oben, Experten-gesucht-Dokument unten in Spalte 2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── DESKTOP MOCKUP — EARLY STATE (Lesefertig leer) ───────────────── -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Mockup — Desktop, frühe Projektphase (Lesefertig leer)</div>
|
||||
|
||||
<div class="frames-row">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="frame-desktop">
|
||||
<div class="f-nav">
|
||||
<div class="f-logo">FAMILIENARCHIV</div>
|
||||
<div class="f-navlinks"><div class="f-navlink on">Archiv</div><div class="f-navlink">Personen</div></div>
|
||||
<div class="f-navr"><div class="f-av">MR</div></div>
|
||||
</div>
|
||||
<div class="f-body">
|
||||
<div class="f-search"><div class="f-si">⌕</div><div class="f-st">Dokumente durchsuchen…</div></div>
|
||||
<div class="f-resume"></div>
|
||||
<div class="f-grid-2">
|
||||
<div class="f-card">
|
||||
<div class="f-ht">Neueste Aktivität</div>
|
||||
<div class="f-row" style="display:flex;"><div><div class="f-dn">Brief von Oma Martha, 1943</div></div><div class="f-dd">12. Apr</div></div>
|
||||
<div class="f-row" style="display:flex;"><div><div class="f-dn">Taufurkunde Karl Raddatz</div></div><div class="f-dd">9. Apr</div></div>
|
||||
<div class="f-stat">1 500 Dokumente · 12 Personen</div>
|
||||
</div>
|
||||
<div class="rhs">
|
||||
<div class="f-dz"><div class="f-dz-i">↑</div><div class="f-dz-t">Datei hochladen</div><div class="f-dz-s">Drag & Drop</div></div>
|
||||
<div class="f-card" style="flex:1;">
|
||||
<div class="f-ht o">Metadaten fehlen</div>
|
||||
<div class="f-row"><div class="f-dn">Familienfoto 1952</div></div>
|
||||
<div class="f-row"><div class="f-dn">Standesamtsurkunde</div></div>
|
||||
<a class="f-lnk">Alle anzeigen →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid var(--sand);border-radius:3px;padding:8px;">
|
||||
<div class="f-ht" style="margin-bottom:7px;">Was braucht Aufmerksamkeit?</div>
|
||||
<div class="f-grid-3">
|
||||
<div class="strip-col seg">
|
||||
<div>
|
||||
<div class="f-ht n" style="margin-bottom:2px;">Rahmen einzeichnen</div>
|
||||
<div class="skill-pill easy">✓ Ohne Vorkenntnisse</div>
|
||||
<div class="pulse"><span class="pulse-num g">↑ +3 diese Woche</span><span class="pulse-open">· 1 498 offen</span></div>
|
||||
<div class="avatars"><div class="av-sm" style="background:var(--navy);">MR</div><div class="av-more">1 Person</div></div>
|
||||
</div>
|
||||
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div></div>
|
||||
<div class="f-row"><div class="f-dn">Standesamt 1889</div></div>
|
||||
<div class="f-row"><div class="f-dn">Heiratsurkunde 1921</div></div>
|
||||
<a class="cta-btn">Jetzt einzeichnen →</a>
|
||||
</div>
|
||||
<div class="strip-col trans">
|
||||
<div>
|
||||
<div class="f-ht n" style="margin-bottom:2px;">Text eintippen</div>
|
||||
<div class="skill-pill kurrent">Kurrent hilfreich</div>
|
||||
<div class="pulse"><span class="pulse-num n">↑ +1 diese Woche</span><span class="pulse-open">· 2 offen</span></div>
|
||||
<div class="avatars"><div class="av-sm" style="background:var(--navy);">MR</div><div class="av-more">1 Person</div></div>
|
||||
</div>
|
||||
<div class="doc-bar-row">
|
||||
<div class="f-dn">Brief v. Oma Martha 1943</div>
|
||||
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 6 Blöcke</div></div>
|
||||
</div>
|
||||
<div class="doc-bar-row">
|
||||
<div class="f-dn">Reisepass Opa Heinrich</div>
|
||||
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 4 Blöcke</div></div>
|
||||
</div>
|
||||
<a class="cta-btn">Jetzt tippen →</a>
|
||||
</div>
|
||||
<!-- Lesefertig EMPTY — cross-column redirect -->
|
||||
<div class="strip-col done-empty">
|
||||
<div style="font-size:11px;color:var(--mint);margin-bottom:3px;">✦</div>
|
||||
<div style="font-size:6.5px;font-weight:700;color:var(--navy);margin-bottom:3px;">Noch kein Dokument lesefertig</div>
|
||||
<div style="font-size:5.5px;color:var(--muted);line-height:1.5;max-width:105px;margin-bottom:5px;">Erscheint hier sobald die Transkription abgeschlossen ist.</div>
|
||||
<a class="cta-btn ghost" style="font-size:5.5px;padding:2px 7px;">Jetzt mithelfen →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="caption">Desktop (55 %) — frühe Phase: 1 500 Dokumente ohne Transkription, Wochenpuls zeigt Schwung statt Berg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ── MOBILE MOCKUP ─────────────────────────────────────────────────── -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Mockup — Mobil 320 px</div>
|
||||
<p class="prose" style="margin-bottom:16px;">
|
||||
Die rechte Spalte (DropZone + Metadaten) erscheint auf Mobil zuerst im DOM (<code>lg:order-last</code> schiebt sie auf Desktop nach rechts).
|
||||
Der Streifen stapelt seine drei Spalten vertikal. Jede Spalte hat volle Breite — keine Overflow-Probleme.
|
||||
</p>
|
||||
|
||||
<div class="frames-row">
|
||||
<!-- Phone: filled state -->
|
||||
<div>
|
||||
<div class="frame-phone" style="height:620px;">
|
||||
<div class="ph-nav"><div class="ph-logo">FAMILIENARCHIV</div></div>
|
||||
<div class="ph-body" style="overflow-y:auto;">
|
||||
<div class="ph-search"><div class="ph-st">⌕ Dokumente…</div></div>
|
||||
<!-- Right col first on mobile -->
|
||||
<div class="f-dz" style="padding:5px;"><div class="f-dz-i" style="font-size:10px;">↑</div><div class="f-dz-t">Hochladen</div></div>
|
||||
<div class="f-card" style="padding:5px;">
|
||||
<div class="f-ht o">Metadaten fehlen</div>
|
||||
<div class="f-row"><div class="f-dn">Familienfoto 1952</div></div>
|
||||
<div class="f-row"><div class="f-dn">Standesamtsurkunde</div></div>
|
||||
</div>
|
||||
<!-- Left col (recent) -->
|
||||
<div class="f-card" style="padding:5px;">
|
||||
<div class="f-ht">Neueste Aktivität</div>
|
||||
<div class="f-row"><div class="f-dn">Brief von Oma Martha</div></div>
|
||||
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div></div>
|
||||
<div class="f-stat">1 500 Dok. · 12 Pers.</div>
|
||||
</div>
|
||||
<!-- Strip — stacked on mobile -->
|
||||
<div style="background:#fff;border:1px solid var(--sand);border-radius:3px;padding:5px;display:flex;flex-direction:column;gap:4px;">
|
||||
<div class="f-ht" style="margin-bottom:3px;">Was braucht Aufmerksamkeit?</div>
|
||||
<!-- Seg -->
|
||||
<div class="strip-col seg" style="padding:5px;">
|
||||
<div class="f-ht n" style="margin-bottom:1px;">Rahmen einzeichnen</div>
|
||||
<div class="skill-pill easy">✓ Ohne Vorkenntnisse</div>
|
||||
<div class="pulse" style="margin-bottom:2px;"><span class="pulse-num g">↑ +5 diese Woche</span><span class="pulse-open">· 1 480 offen</span></div>
|
||||
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div></div>
|
||||
<div class="f-row"><div class="f-dn">Standesamt 1889</div></div>
|
||||
<a class="cta-btn" style="font-size:6px;">Jetzt einzeichnen →</a>
|
||||
</div>
|
||||
<!-- Trans -->
|
||||
<div class="strip-col trans" style="padding:5px;">
|
||||
<div class="f-ht n" style="margin-bottom:1px;">Text eintippen</div>
|
||||
<div class="skill-pill kurrent">Kurrent hilfreich</div>
|
||||
<div class="pulse" style="margin-bottom:2px;"><span class="pulse-num n">↑ +2 diese Woche</span><span class="pulse-open">· 8 offen</span></div>
|
||||
<div class="doc-bar-row">
|
||||
<div class="f-dn">Reisepass Opa Heinrich</div>
|
||||
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:37%;"></div></div><div class="bar-label">3 / 8 Blöcke</div></div>
|
||||
</div>
|
||||
<div class="doc-bar-row">
|
||||
<div class="f-dn">Brief v. Oma Martha 1943</div>
|
||||
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 6 Blöcke</div></div>
|
||||
</div>
|
||||
<a class="cta-btn" style="font-size:6px;">Jetzt tippen →</a>
|
||||
</div>
|
||||
<!-- Lesefertig -->
|
||||
<div class="strip-col done" style="padding:5px;">
|
||||
<div class="f-ht g" style="margin-bottom:1px;">Lesefertig ✓</div>
|
||||
<div style="font-size:5.5px;color:var(--green);font-weight:600;margin-bottom:3px;">3 bereit</div>
|
||||
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);"><div class="f-dn">Postkarte 1943</div><div style="font-size:5.5px;color:var(--green);font-weight:600;">100 %</div></div>
|
||||
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);"><div class="f-dn">Brief Oma 1938</div><div style="font-size:5.5px;color:var(--green);font-weight:600;">95 %</div></div>
|
||||
<a class="f-lnk g">Alle lesen →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="caption">Mobil 320 px — Streifen stapelt vertikal, volle Breite je Spalte</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layout notes -->
|
||||
<div style="flex:1;min-width:220px;">
|
||||
<div style="background:#fff;border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:12px;">
|
||||
<div style="font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--navy);margin-bottom:8px;">Mobile-Reihenfolge (DOM)</div>
|
||||
<ol style="font-size:12px;color:var(--muted);line-height:1.8;margin-left:16px;">
|
||||
<li>Suchleiste</li>
|
||||
<li>DropZone (write users only)</li>
|
||||
<li>Metadaten fehlen</li>
|
||||
<li>Neueste Aktivität</li>
|
||||
<li>Was braucht Aufmerksamkeit?
|
||||
<ol style="margin-left:16px;">
|
||||
<li>Rahmen einzeichnen</li>
|
||||
<li>Text eintippen</li>
|
||||
<li>Lesefertig ✓</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="callout navy">
|
||||
<div>
|
||||
<strong class="n">Touch targets:</strong> Alle CTA-Buttons: <code>min-h-[44px]</code> (WCAG 2.2).
|
||||
Dokument-Zeilen in den Spalten: <code>min-h-[44px] py-2</code>.
|
||||
Der „Zu schwer"-Button auf der Enrich-Seite: <code>min-h-[44px]</code> als Icon-Button mit <code>aria-label</code>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ── ENGAGEMENT FEATURES SUMMARY ──────────────────────────────────── -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Engagement-Elemente — Zusammenfassung</div>
|
||||
<div class="comp-grid">
|
||||
<div class="comp-card">
|
||||
<h4>① Skill-Pill</h4>
|
||||
<p>Unter jedem Spaltentitel. „Ohne Vorkenntnisse" (grün) vs. „Kurrent hilfreich" (navy-neutral).
|
||||
Senkt die Hemmschwelle — Neueinsteiger sehen sofort, was ohne Kurrent-Kenntnisse möglich ist.</p>
|
||||
<p style="margin-top:6px;"><code>bg-green-50 border-green-200 text-green-800</code> / <code>bg-surface border-line text-ink</code></p>
|
||||
</div>
|
||||
<div class="comp-card">
|
||||
<h4>② Wochenpuls</h4>
|
||||
<p>„↑ +5 diese Woche · 1 480 offen" statt globalem Fortschrittsbalken.
|
||||
Zeigt Schwung, nicht den Berg. Psychologisch: 0,8 %-Balken ist demotivierender als kein Balken.</p>
|
||||
<p style="margin-top:6px;"><code>SELECT COUNT(*) WHERE created_at > NOW() - INTERVAL '7 days'</code></p>
|
||||
</div>
|
||||
<div class="comp-card">
|
||||
<h4>③ Per-Dokument-Balken</h4>
|
||||
<p>Nur in Spalte 2, nur wenn <code>annotation_count > 0</code>. Richtiger Maßstab:
|
||||
8 Blöcke sind in einer Sitzung abschließbar. Zeigt auch, welche Dokumente „fast fertig" sind.</p>
|
||||
<p style="margin-top:6px;"><code>width: {textedBlocks / totalBlocks * 100}%</code>; Guard: <code>totalBlocks === 0 → width: 0</code></p>
|
||||
</div>
|
||||
<div class="comp-card">
|
||||
<h4>④ Contributor-Avatare</h4>
|
||||
<p>Max. 3 Initialen-Bubbles der letzten Beitragenden pro Spalte. Kein Leaderboard (Wettbewerb) —
|
||||
soziale Sichtbarkeit (Zugehörigkeit). Farbe deterministisch aus User-ID-Hash.</p>
|
||||
<p style="margin-top:6px;">DTO: <code>lastContributors: [{initials, colorIndex}]</code> — nur Initialen, keine Namen (Nora)</p>
|
||||
</div>
|
||||
<div class="comp-card">
|
||||
<h4>⑤ „Starte hier →"-CTA</h4>
|
||||
<p>Ein einziger opinionated Button je Aufgaben-Spalte, der direkt zum nächsten Dokument springt.
|
||||
Entscheidungslähmung ist der Hauptgrund für Non-Participation bei Familienprojekten.</p>
|
||||
<p style="margin-top:6px;"><code>/enrich?filter=NEEDS_SEGMENTATION&next=1</code> (Segmentierung)<br/><code>/enrich?filter=NEEDS_TRANSCRIPTION&next=1</code> (Transkription)</p>
|
||||
</div>
|
||||
<div class="comp-card">
|
||||
<h4>⑥ Lesefertig-Leerstand → Redirect</h4>
|
||||
<p>Wenn Spalte 3 leer ist (frühe Phase), erscheint kein toter Endpunkt sondern:
|
||||
„Erscheint hier, sobald die Transkription abgeschlossen ist — jetzt mithelfen →".
|
||||
Der Link springt zu Spalte 1.</p>
|
||||
<p style="margin-top:6px;"><code>{#if readyToRead.length === 0}</code> → <code>DashboardReadyToReadEmpty.svelte</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ── IMPL-REF TABLE ────────────────────────────────────────────────── -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Implementation Reference</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>Tailwind-Klassen</th><th>Pixel / Wert</th><th>Hinweis</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Streifen-Wrapper</strong></td><td><code>mt-4 bg-white border border-line rounded-sm p-6</code></td><td>padding 24 px</td><td>Direkt nach bestehendem <code>div.mt-4.grid</code></td></tr>
|
||||
<tr><td>Streifen-Titel</td><td><code>text-xs font-bold uppercase tracking-widest text-gray-400 mb-4</code></td><td>12 px / 700</td><td>Standard-Section-Title-Muster</td></tr>
|
||||
<tr><td>3-Spalten-Grid</td><td><code>grid grid-cols-1 gap-4 sm:grid-cols-3</code></td><td>gap 16 px</td><td>sm = 640 px; darunter stapeln</td></tr>
|
||||
<tr><td>Segmentierung-Spalte</td><td><code>bg-surface rounded-sm border border-line p-4 flex flex-col gap-3</code></td><td>—</td><td>Neutral</td></tr>
|
||||
<tr><td>Transkription-Spalte</td><td><code>bg-surface rounded-sm border border-line p-4 flex flex-col gap-3</code></td><td>—</td><td>Neutral — es ist eine Aufgabe</td></tr>
|
||||
<tr><td>Lesefertig-Spalte (gefüllt)</td><td><code>bg-mint/10 rounded-sm border border-mint p-4 flex flex-col gap-3</code></td><td>—</td><td>Mint-Ton = Erfolg</td></tr>
|
||||
<tr><td>Lesefertig-Spalte (leer)</td><td><code>flex flex-col items-center justify-center text-center bg-mint/5 border border-dashed border-mint rounded-sm p-6 min-h-[120px]</code></td><td>min-h 120 px</td><td>Kein toter Endpunkt</td></tr>
|
||||
<tr><td>Skill-Pill easy</td><td><code>inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-50 border border-green-200 text-green-800</code></td><td>Kontrast 9,7:1 ✓ AAA</td><td>—</td></tr>
|
||||
<tr><td>Skill-Pill kurrent</td><td><code>inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-surface border border-line text-ink</code></td><td>Kontrast 14,5:1 ✓ AAA</td><td>Neutral — kein Abschreck-Signal</td></tr>
|
||||
<tr><td>Wochenpuls-Zahl</td><td><code>text-xs font-semibold text-green-700</code> (Seg.) / <code>text-ink</code> (Trans.)</td><td>12 px</td><td>Kein globaler Balken</td></tr>
|
||||
<tr><td>Per-Dokument-Track</td><td><code>flex-1 h-1 bg-navy/20 rounded-full overflow-hidden</code></td><td>h 4 px</td><td>Nur wenn <code>annotation_count > 0</code></td></tr>
|
||||
<tr><td>Per-Dokument-Fill</td><td><code>h-full bg-ink rounded-full transition-all</code> + <code>style="width:{pct}%"</code></td><td>—</td><td>Guard: <code>totalBlocks === 0 → 0%</code></td></tr>
|
||||
<tr><td>Lesefertig-Prozent</td><td><code>text-xs font-semibold text-green-800</code></td><td>12 px</td><td>Kein Balken — mint-Spalte ist das Signal</td></tr>
|
||||
<tr><td>Contributor-Avatar</td><td><code>w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shrink-0</code></td><td>24 × 24 px</td><td>Farbe: 6 Werte, Index = <code>userIdHash % 6</code></td></tr>
|
||||
<tr><td>CTA-Button (primär)</td><td><code>block w-full text-center text-xs font-semibold text-white bg-ink rounded-sm py-2 mt-2 hover:bg-ink-2 transition-colors focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-1</code></td><td>min-h 36 px</td><td><code>aria-label</code> mit Dokumenttitel falls nötig</td></tr>
|
||||
<tr><td>CTA-Button (ghost, Leerstand)</td><td><code>inline-flex items-center text-xs font-semibold text-ink border border-ink rounded-sm px-3 py-2 hover:bg-ink hover:text-white transition-colors</code></td><td>min-h 36 px</td><td>—</td></tr>
|
||||
<tr><td>Experten-gesucht-Badge</td><td><code>inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-purple-50 border border-purple-200 text-purple-700</code></td><td>Kontrast 6,8:1 ✓ AA</td><td>Nur wenn <code>doc.needsExpert === true</code></td></tr>
|
||||
<tr><td>Sichtbarkeit Streifen</td><td><code>{#if needsSegmentation.length > 0 || needsTranscription.length > 0 || readyToRead.length > 0}</code></td><td>—</td><td>Streifen verschwindet wenn alle drei Buckets leer</td></tr>
|
||||
<tr><td>Dokument-Zeile Mindesthöhe</td><td><code>min-h-[44px] flex items-start py-2</code></td><td>44 px ✓ WCAG 2.2</td><td>Gilt für alle klickbaren Zeilen</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ── BACKEND CONTRACTS ─────────────────────────────────────────────── -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Backend — neue Endpoints & Queries</div>
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<thead><tr><th>Endpoint / Query</th><th>Bedingung</th><th>Sort</th><th>Auth</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>GET /api/documents/needs-segmentation?size=3</code></td><td><code>NOT EXISTS (SELECT 1 FROM document_annotations WHERE document_id = d.id)</code></td><td><code>HASHTEXT(id::text || week::text)</code></td><td><code>READ_ALL</code></td></tr>
|
||||
<tr><td><code>GET /api/documents/needs-transcription?size=3</code></td><td><code>EXISTS annotation AND (no blocks OR reviewed_pct < 0.90)</code></td><td><code>textedBlocks DESC, needs_expert ASC, HASHTEXT(...)</code></td><td><code>READ_ALL</code></td></tr>
|
||||
<tr><td><code>GET /api/documents/ready-to-read?size=3</code></td><td><code>reviewed_pct >= 0.90</code></td><td><code>updated_at DESC</code></td><td><code>READ_ALL</code></td></tr>
|
||||
<tr><td><code>PATCH /api/documents/{id}/needs-expert</code></td><td>Setzt <code>needs_expert = true</code></td><td>—</td><td><code>READ_ALL</code> (jeder Nutzer darf flaggen)</td></tr>
|
||||
<tr><td><code>GET /api/stats/strip-activity</code></td><td>Wochenpuls: <code>COUNT(*) WHERE created_at > NOW() - INTERVAL '7 days'</code> pro Bucket</td><td>—</td><td><code>READ_ALL</code></td></tr>
|
||||
<tr><td>Flyway-Migration</td><td><code>ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE</code></td><td>—</td><td>V{n}__add_needs_expert_flag.sql</td></tr>
|
||||
<tr><td>Index prüfen (Tobias)</td><td><code>document_annotations(document_id)</code>, <code>transcription_blocks(document_id, reviewed)</code></td><td>—</td><td>EXPLAIN ANALYZE vor Merge</td></tr>
|
||||
<tr><td>Division durch 0 (Sara)</td><td>Alle reviewed_pct-Queries: <code>CASE WHEN COUNT(*) = 0 THEN 0 ELSE SUM(...)::float / COUNT(*) END</code></td><td>—</td><td>—</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- ── NEW COMPONENTS ────────────────────────────────────────────────── -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">Neue Svelte-Komponenten</div>
|
||||
<div class="comp-grid">
|
||||
<div class="comp-card">
|
||||
<h4><code>DashboardMissionControl.svelte</code></h4>
|
||||
<p>Wrapper für den vollbreiten Streifen. Props: <code>needsSegmentation</code>, <code>needsTranscription</code>,
|
||||
<code>readyToRead</code>, <code>weeklyActivity</code>. Rendert die drei Spalten und ist komplett unsichtbar wenn alle Arrays leer sind.</p>
|
||||
</div>
|
||||
<div class="comp-card">
|
||||
<h4><code>DashboardSegmentationCol.svelte</code></h4>
|
||||
<p>Spalte 1: Skill-Pill, Wochenpuls, Avatare, Dokumentliste, CTA. Keine Balken — keine Dokument-Metadaten vorhanden.</p>
|
||||
</div>
|
||||
<div class="comp-card">
|
||||
<h4><code>DashboardTranscriptionCol.svelte</code></h4>
|
||||
<p>Spalte 2: Skill-Pill, Wochenpuls, Avatare, per-Dokument-Balken, Experten-Badge bei <code>needsExpert</code>, CTA.</p>
|
||||
</div>
|
||||
<div class="comp-card">
|
||||
<h4><code>DashboardReadyToReadCol.svelte</code></h4>
|
||||
<p>Spalte 3: Zeigt gefüllten Zustand (Liste mit %-Text) oder leeren Zustand (Cross-Column-Redirect zu Segmentierung).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="callout green">
|
||||
<div>
|
||||
<strong class="g">Bestehende Komponente bleibt:</strong> <code>DashboardNeedsMetadata.svelte</code> ist unverändert —
|
||||
sie lebt weiterhin in der rechten Spalte. Der Mission-Control-Streifen ist vollständig additiv und ändert nichts am bestehenden Layout.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /doc -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -78,6 +78,8 @@
|
||||
"docs_empty_btn_clear": "Alle Filter löschen",
|
||||
"docs_list_from": "Von",
|
||||
"docs_list_to": "An",
|
||||
"docs_list_content": "Inhalt",
|
||||
"docs_list_summary": "Zusammenfassung",
|
||||
"docs_list_unknown": "Unbekannt",
|
||||
"docs_group_undated": "Undatiert",
|
||||
"docs_group_unknown": "Unbekannt",
|
||||
@@ -555,5 +557,23 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
"docs_empty_btn_clear": "Clear all filters",
|
||||
"docs_list_from": "From",
|
||||
"docs_list_to": "To",
|
||||
"docs_list_content": "Content",
|
||||
"docs_list_summary": "Summary",
|
||||
"docs_list_unknown": "Unknown",
|
||||
"docs_group_undated": "Undated",
|
||||
"docs_group_unknown": "Unknown",
|
||||
@@ -555,5 +557,23 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
"docs_empty_btn_clear": "Borrar todos los filtros",
|
||||
"docs_list_from": "De",
|
||||
"docs_list_to": "Para",
|
||||
"docs_list_content": "Contenido",
|
||||
"docs_list_summary": "Resumen",
|
||||
"docs_list_unknown": "Desconocido",
|
||||
"docs_group_undated": "Sin fecha",
|
||||
"docs_group_unknown": "Desconocido",
|
||||
@@ -555,5 +557,23 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
26
frontend/src/lib/components/ExpertBadge.svelte
Normal file
26
frontend/src/lib/components/ExpertBadge.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
50
frontend/src/lib/components/MissionControlStrip.svelte
Normal file
50
frontend/src/lib/components/MissionControlStrip.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<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}
|
||||
@@ -56,21 +56,20 @@ onMount(async () => {
|
||||
await renderer.init();
|
||||
});
|
||||
|
||||
// Wire DOM elements to the renderer after they mount
|
||||
$effect(() => {
|
||||
if (canvasEl && textLayerEl) {
|
||||
renderer.setElements(canvasEl, textLayerEl);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (renderer.pdfjsReady && url) {
|
||||
renderer.loadDocument(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Wire DOM elements to the renderer and trigger rendering.
|
||||
// canvasEl is read synchronously so Svelte tracks it as a dependency:
|
||||
// when the canvas reappears after the loading spinner (loading → false),
|
||||
// this effect re-fires and renders the already-loaded PDF.
|
||||
$effect(() => {
|
||||
// Read scale and currentPage synchronously so Svelte tracks them as dependencies.
|
||||
if (!canvasEl || !textLayerEl) return;
|
||||
renderer.setElements(canvasEl, textLayerEl);
|
||||
// Also track currentPage and scale so page-nav / zoom re-renders work.
|
||||
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
|
||||
renderer.renderCurrentPage().then(() => renderer.prerender());
|
||||
}
|
||||
|
||||
90
frontend/src/lib/components/ReadyColumn.svelte
Normal file
90
frontend/src/lib/components/ReadyColumn.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<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}
|
||||
70
frontend/src/lib/components/SegmentationColumn.svelte
Normal file
70
frontend/src/lib/components/SegmentationColumn.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<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}
|
||||
93
frontend/src/lib/components/TranscriptionColumn.svelte
Normal file
93
frontend/src/lib/components/TranscriptionColumn.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<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}
|
||||
@@ -628,6 +628,22 @@ export interface paths {
|
||||
patch: operations["editComment"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations/{annotationId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteAnnotation"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["updateAnnotation"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/search": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -660,6 +676,32 @@ 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;
|
||||
@@ -1060,22 +1102,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations/{annotationId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteAnnotation"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -1199,6 +1225,7 @@ 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"][];
|
||||
@@ -1440,28 +1467,60 @@ 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 */
|
||||
@@ -1508,6 +1567,8 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
totalElements?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
content?: components["schemas"]["NotificationDTO"][];
|
||||
@@ -1516,8 +1577,6 @@ export interface components {
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
empty?: boolean;
|
||||
};
|
||||
PageableObject: {
|
||||
@@ -1578,9 +1637,28 @@ export interface components {
|
||||
totalPages?: number;
|
||||
};
|
||||
DocumentSearchResult: {
|
||||
documents?: components["schemas"]["Document"][];
|
||||
documents: components["schemas"]["Document"][];
|
||||
/** Format: int64 */
|
||||
total?: number;
|
||||
total: number;
|
||||
matchData: {
|
||||
[key: string]: components["schemas"]["SearchMatchData"];
|
||||
};
|
||||
};
|
||||
MatchOffset: {
|
||||
/** Format: int32 */
|
||||
start: number;
|
||||
/** Format: int32 */
|
||||
length: number;
|
||||
};
|
||||
SearchMatchData: {
|
||||
transcriptionSnippet?: string;
|
||||
titleOffsets: components["schemas"]["MatchOffset"][];
|
||||
senderMatched: boolean;
|
||||
matchedReceiverIds: string[];
|
||||
matchedTagIds: string[];
|
||||
snippetOffsets: components["schemas"]["MatchOffset"][];
|
||||
summarySnippet?: string;
|
||||
summaryOffsets: components["schemas"]["MatchOffset"][];
|
||||
};
|
||||
IncompleteDocumentDTO: {
|
||||
/** Format: uuid */
|
||||
@@ -2938,8 +3016,8 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
@@ -2995,6 +3073,54 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteAnnotation: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
annotationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
updateAnnotation: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
annotationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateAnnotationDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentAnnotation"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
search: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -3039,6 +3165,46 @@ 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;
|
||||
@@ -3425,7 +3591,7 @@ export interface operations {
|
||||
/** @description Filter by document status */
|
||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
/** @description Sort field */
|
||||
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE";
|
||||
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "RELEVANCE";
|
||||
/** @description Sort direction: ASC or DESC */
|
||||
dir?: string;
|
||||
};
|
||||
@@ -3602,25 +3768,4 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteAnnotation: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
annotationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
export function createFileLoader() {
|
||||
let fileUrl = $state('');
|
||||
let isLoading = $state(false);
|
||||
@@ -6,7 +8,11 @@ export function createFileLoader() {
|
||||
async function loadFile(url: string): Promise<void> {
|
||||
isLoading = true;
|
||||
fileError = '';
|
||||
if (fileUrl) URL.revokeObjectURL(fileUrl);
|
||||
// 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);
|
||||
fileUrl = '';
|
||||
|
||||
try {
|
||||
|
||||
110
frontend/src/lib/search.spec.ts
Normal file
110
frontend/src/lib/search.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyOffsets } from './search';
|
||||
|
||||
describe('applyOffsets', () => {
|
||||
it('returns single plain segment when offsets is empty', () => {
|
||||
expect(applyOffsets('Hallo Welt', [])).toEqual([{ text: 'Hallo Welt', highlight: false }]);
|
||||
});
|
||||
|
||||
it('highlights a single term at the start', () => {
|
||||
expect(applyOffsets('Brief an Anna', [{ start: 0, length: 5 }])).toEqual([
|
||||
{ text: 'Brief', highlight: true },
|
||||
{ text: ' an Anna', highlight: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('highlights a term in the middle', () => {
|
||||
expect(applyOffsets('Der Brief von Anna', [{ start: 4, length: 5 }])).toEqual([
|
||||
{ text: 'Der ', highlight: false },
|
||||
{ text: 'Brief', highlight: true },
|
||||
{ text: ' von Anna', highlight: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('highlights a term at the end', () => {
|
||||
expect(applyOffsets('Brief an Anna', [{ start: 9, length: 4 }])).toEqual([
|
||||
{ text: 'Brief an ', highlight: false },
|
||||
{ text: 'Anna', highlight: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles two non-overlapping offsets in order', () => {
|
||||
expect(
|
||||
applyOffsets('Anna und Brief', [
|
||||
{ start: 0, length: 4 },
|
||||
{ start: 9, length: 5 }
|
||||
])
|
||||
).toEqual([
|
||||
{ text: 'Anna', highlight: true },
|
||||
{ text: ' und ', highlight: false },
|
||||
{ text: 'Brief', highlight: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges overlapping offsets into the longest span', () => {
|
||||
// [0,7) and [3,9) overlap → merged [0,max(7,9)) = [0,9) = "Hello wor"
|
||||
expect(
|
||||
applyOffsets('Hello world', [
|
||||
{ start: 0, length: 7 },
|
||||
{ start: 3, length: 6 }
|
||||
])
|
||||
).toEqual([
|
||||
{ text: 'Hello wor', highlight: true },
|
||||
{ text: 'ld', highlight: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges adjacent (touching) offsets', () => {
|
||||
// [0,3) and [3,6) are adjacent → merged [0,6)
|
||||
expect(
|
||||
applyOffsets('Hallo Welt', [
|
||||
{ start: 0, length: 3 },
|
||||
{ start: 3, length: 3 }
|
||||
])
|
||||
).toEqual([
|
||||
{ text: 'Hallo ', highlight: true },
|
||||
{ text: 'Welt', highlight: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps offset that extends beyond text length', () => {
|
||||
expect(applyOffsets('Hi', [{ start: 0, length: 100 }])).toEqual([
|
||||
{ text: 'Hi', highlight: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a completely out-of-bounds offset', () => {
|
||||
expect(applyOffsets('Hi', [{ start: 10, length: 5 }])).toEqual([
|
||||
{ text: 'Hi', highlight: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts unsorted offsets correctly', () => {
|
||||
// Offsets provided in reverse order: second term first
|
||||
expect(
|
||||
applyOffsets('Anna und Brief', [
|
||||
{ start: 9, length: 5 },
|
||||
{ start: 0, length: 4 }
|
||||
])
|
||||
).toEqual([
|
||||
{ text: 'Anna', highlight: true },
|
||||
{ text: ' und ', highlight: false },
|
||||
{ text: 'Brief', highlight: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps negative start to 0 and highlights from the beginning', () => {
|
||||
// start = -2, length = 5 → effective range [-2, 3) → clamped to [0, 3)
|
||||
expect(applyOffsets('Hello', [{ start: -2, length: 5 }])).toEqual([
|
||||
{ text: 'Hel', highlight: true },
|
||||
{ text: 'lo', highlight: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores offset whose end is also negative', () => {
|
||||
// start = -5, length = 2 → end = -3, completely before text
|
||||
expect(applyOffsets('Hi', [{ start: -5, length: 2 }])).toEqual([
|
||||
{ text: 'Hi', highlight: false }
|
||||
]);
|
||||
});
|
||||
});
|
||||
46
frontend/src/lib/search.ts
Normal file
46
frontend/src/lib/search.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type TextSegment = { text: string; highlight: boolean };
|
||||
|
||||
export type MatchOffset = { start: number; length: number };
|
||||
|
||||
/**
|
||||
* Converts a flat string and a list of character-level highlight offsets into
|
||||
* an array of text segments that can be rendered without {@html}.
|
||||
*
|
||||
* Offsets are sorted and merged (overlapping spans become the longest enclosing
|
||||
* span) before processing. Out-of-bounds offsets are clamped or dropped.
|
||||
*
|
||||
* @param text The display text (no delimiter characters).
|
||||
* @param offsets Character offsets produced by the backend (Java char positions,
|
||||
* compatible with JavaScript String indexing).
|
||||
*/
|
||||
export function applyOffsets(text: string, offsets: MatchOffset[]): TextSegment[] {
|
||||
if (!offsets.length) return [{ text, highlight: false }];
|
||||
|
||||
// Sort by start position and merge overlapping / adjacent spans
|
||||
const sorted = [...offsets].sort((a, b) => a.start - b.start);
|
||||
const merged: { start: number; end: number }[] = [];
|
||||
for (const { start, length } of sorted) {
|
||||
const end = start + length;
|
||||
if (end <= 0 || start >= text.length) continue; // completely out of bounds
|
||||
const clampedStart = Math.max(0, start);
|
||||
const clampedEnd = Math.min(text.length, end);
|
||||
const last = merged[merged.length - 1];
|
||||
if (!last || clampedStart > last.end) {
|
||||
merged.push({ start: clampedStart, end: clampedEnd });
|
||||
} else {
|
||||
last.end = Math.max(last.end, clampedEnd);
|
||||
}
|
||||
}
|
||||
|
||||
if (!merged.length) return [{ text, highlight: false }];
|
||||
|
||||
const segments: TextSegment[] = [];
|
||||
let pos = 0;
|
||||
for (const { start, end } of merged) {
|
||||
if (pos < start) segments.push({ text: text.slice(pos, start), highlight: false });
|
||||
segments.push({ text: text.slice(start, end), highlight: true });
|
||||
pos = end;
|
||||
}
|
||||
if (pos < text.length) segments.push({ text: text.slice(pos), highlight: false });
|
||||
return segments;
|
||||
}
|
||||
@@ -5,6 +5,9 @@ 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') || '';
|
||||
@@ -60,9 +63,14 @@ export async function load({ url, fetch }) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
const searchResult = docsResult?.data as { documents?: Document[]; total?: number } | null;
|
||||
const searchResult = docsResult?.data as {
|
||||
documents?: Document[];
|
||||
total?: number;
|
||||
matchData?: Record<string, SearchMatchData>;
|
||||
} | null;
|
||||
const documents: Document[] = searchResult?.documents ?? [];
|
||||
const total: number = searchResult?.total ?? 0;
|
||||
const matchData: Record<string, SearchMatchData> = searchResult?.matchData ?? {};
|
||||
const allPersons = (personsResult.data ?? []) as {
|
||||
id: string;
|
||||
firstName: string;
|
||||
@@ -76,12 +84,28 @@ 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] = await Promise.allSettled([
|
||||
const [
|
||||
statsResult,
|
||||
incompleteResult,
|
||||
recentResult,
|
||||
segmentationResult,
|
||||
transcriptionResult,
|
||||
readyResult,
|
||||
weeklyStatsResult
|
||||
] = 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/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')
|
||||
]);
|
||||
|
||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||
@@ -93,15 +117,32 @@ 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 ?? ''
|
||||
@@ -116,9 +157,14 @@ 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
|
||||
|
||||
@@ -9,6 +9,7 @@ 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();
|
||||
@@ -132,6 +133,13 @@ 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 ?? []}
|
||||
@@ -140,6 +148,7 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
||||
total={data.total ?? 0}
|
||||
q={q}
|
||||
sort={sort}
|
||||
matchData={data.matchData ?? {}}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { groupDocuments } from '$lib/utils/groupDocuments';
|
||||
import GroupDivider from '$lib/components/GroupDivider.svelte';
|
||||
import { applyOffsets } from '$lib/search';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
let {
|
||||
documents,
|
||||
@@ -11,7 +13,8 @@ let {
|
||||
error,
|
||||
total = 0,
|
||||
q = '',
|
||||
sort
|
||||
sort,
|
||||
matchData = {}
|
||||
}: {
|
||||
documents: {
|
||||
id: string;
|
||||
@@ -19,8 +22,13 @@ let {
|
||||
originalFilename: string;
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
|
||||
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
|
||||
sender?: {
|
||||
id?: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
}[];
|
||||
canWrite: boolean;
|
||||
@@ -28,6 +36,7 @@ let {
|
||||
total?: number;
|
||||
q?: string;
|
||||
sort?: string;
|
||||
matchData?: Record<string, components['schemas']['SearchMatchData']>;
|
||||
} = $props();
|
||||
|
||||
const fallbackLabel = $derived(
|
||||
@@ -75,6 +84,17 @@ const showDividers = $derived(groupedDocuments.length >= 2);
|
||||
{/if}
|
||||
<ul class="divide-y divide-line-2">
|
||||
{#each group.documents as doc (doc.id)}
|
||||
{@const titleText = doc.title || doc.originalFilename}
|
||||
{@const match = matchData?.[doc.id]}
|
||||
{@const titleOffsets = match?.titleOffsets ?? []}
|
||||
{@const titleSegments = applyOffsets(titleText, titleOffsets)}
|
||||
{@const snippet = match?.transcriptionSnippet}
|
||||
{@const snippetSegments = snippet ? applyOffsets(snippet, match?.snippetOffsets ?? []) : null}
|
||||
{@const summary = match?.summarySnippet}
|
||||
{@const summarySegments = summary ? applyOffsets(summary, match?.summaryOffsets ?? []) : null}
|
||||
{@const senderMatched = match?.senderMatched ?? false}
|
||||
{@const matchedReceiverIds = new Set(match?.matchedReceiverIds ?? [])}
|
||||
{@const matchedTagIds = new Set(match?.matchedTagIds ?? [])}
|
||||
<li class="group transition-colors duration-200 hover:bg-muted/50">
|
||||
<a href="/documents/{doc.id}" class="block p-6">
|
||||
<div class="flex flex-col gap-6 sm:flex-row">
|
||||
@@ -82,7 +102,12 @@ const showDividers = $derived(groupedDocuments.length >= 2);
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-baseline justify-between">
|
||||
<h3 class="font-serif text-xl font-medium text-ink group-hover:underline">
|
||||
{doc.title || doc.originalFilename}
|
||||
{#each titleSegments as seg, i (i)}
|
||||
{#if seg.highlight}<mark
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{seg.text}</mark
|
||||
>{:else}{seg.text}{/if}
|
||||
{/each}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -110,6 +135,42 @@ const showDividers = $derived(groupedDocuments.length >= 2);
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if snippetSegments}
|
||||
<div class="mb-4 flex items-baseline gap-2">
|
||||
<span
|
||||
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{m.docs_list_content()}</span
|
||||
>
|
||||
<p
|
||||
data-testid="search-snippet"
|
||||
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
|
||||
>
|
||||
{#each snippetSegments as seg, i (i)}{#if seg.highlight}<mark
|
||||
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{seg.text}</mark
|
||||
>{:else}{seg.text}{/if}{/each}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if summarySegments}
|
||||
<div class="mb-4 flex items-baseline gap-2">
|
||||
<span
|
||||
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{m.docs_list_summary()}</span
|
||||
>
|
||||
<p
|
||||
data-testid="search-summary"
|
||||
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
|
||||
>
|
||||
{#each summarySegments as seg, i (i)}{#if seg.highlight}<mark
|
||||
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{seg.text}</mark
|
||||
>{:else}{seg.text}{/if}{/each}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sender/Receiver Info -->
|
||||
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
|
||||
<div class="flex items-baseline">
|
||||
@@ -118,7 +179,15 @@ const showDividers = $derived(groupedDocuments.length >= 2);
|
||||
>{m.docs_list_from()}</span
|
||||
>
|
||||
{#if doc.sender}
|
||||
<span class="text-ink">{doc.sender.displayName}</span>
|
||||
{#if senderMatched}
|
||||
<mark
|
||||
data-testid="sender-match"
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{doc.sender.displayName}</mark
|
||||
>
|
||||
{:else}
|
||||
<span class="text-ink">{doc.sender.displayName}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
@@ -130,7 +199,18 @@ const showDividers = $derived(groupedDocuments.length >= 2);
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<span class="text-ink">
|
||||
{doc.receivers.map((p) => p.displayName).join(', ')}
|
||||
{#each doc.receivers as receiver, ri (receiver.id ?? ri)}
|
||||
{#if ri > 0}<span>, </span>{/if}
|
||||
{#if receiver.id && matchedReceiverIds.has(receiver.id)}
|
||||
<mark
|
||||
data-testid="receiver-match"
|
||||
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
|
||||
>{receiver.displayName}</mark
|
||||
>
|
||||
{:else}
|
||||
{receiver.displayName}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
@@ -144,14 +224,18 @@ const showDividers = $derived(groupedDocuments.length >= 2);
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-muted px-2 py-1 text-[10px] font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
|
||||
class="relative z-10 inline-flex cursor-pointer items-center rounded px-2 py-1 text-[10px] font-bold tracking-widest uppercase transition-colors hover:bg-primary hover:text-primary-fg {matchedTagIds.has(tag.id) ? 'bg-muted text-ink underline decoration-brand-navy decoration-2 underline-offset-2' : 'bg-muted text-ink'}"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
goto(`/?tag=${encodeURIComponent(tag.name)}`);
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
{#if matchedTagIds.has(tag.id)}
|
||||
<span data-testid="tag-match">{tag.name}</span>
|
||||
{:else}
|
||||
{tag.name}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -12,14 +12,20 @@ const baseProps = {
|
||||
canWrite: false,
|
||||
error: null,
|
||||
total: 0,
|
||||
q: ''
|
||||
q: '',
|
||||
matchData: {} as Record<
|
||||
string,
|
||||
import('$lib/generated/api').components['schemas']['SearchMatchData']
|
||||
>
|
||||
};
|
||||
|
||||
type DocOverrides = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
documentDate?: string | null;
|
||||
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
|
||||
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
|
||||
sender?: { id?: string; firstName?: string | null; lastName: string; displayName: string } | null;
|
||||
receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
const makeDoc = (overrides: DocOverrides = {}) => ({
|
||||
@@ -30,7 +36,12 @@ const makeDoc = (overrides: DocOverrides = {}) => ({
|
||||
documentDate: '2024-03-15',
|
||||
location: null,
|
||||
sender: null,
|
||||
receivers: [] as { firstName?: string | null; lastName: string; displayName: string }[],
|
||||
receivers: [] as {
|
||||
id?: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
}[],
|
||||
tags: [],
|
||||
...overrides
|
||||
});
|
||||
@@ -115,3 +126,248 @@ describe('DocumentList – group headers', () => {
|
||||
await expect.element(links.nth(1)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Match data: snippet and title highlighting ───────────────────────────────
|
||||
|
||||
describe('DocumentList – match snippets and highlights', () => {
|
||||
it('shows transcription snippet when matchData has one for the document', async () => {
|
||||
const doc = makeDoc({ id: 'doc1' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: 'Er schrieb einen langen Brief',
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show snippet section when matchData has no entry for the document', async () => {
|
||||
const doc = makeDoc({ id: 'doc1' });
|
||||
render(DocumentList, { ...baseProps, documents: [doc], total: 1, matchData: {} });
|
||||
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a <mark> element when titleOffsets are present', async () => {
|
||||
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [{ start: 0, length: 5 }], // "Brief"
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
// The word "Brief" should be inside a <mark> element
|
||||
const mark = page.getByRole('mark');
|
||||
await expect.element(mark).toBeInTheDocument();
|
||||
await expect.element(mark).toHaveTextContent('Brief');
|
||||
});
|
||||
|
||||
it('renders title as plain text when titleOffsets is empty', async () => {
|
||||
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
await expect.element(page.getByRole('mark')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Brief an Anna')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders <mark> inside snippet when snippetOffsets are present', async () => {
|
||||
const doc = makeDoc({ id: 'doc1' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: 'Er schrieb einen Brief',
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [{ start: 17, length: 5 }], // "Brief"
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
const snippet = page.getByTestId('search-snippet');
|
||||
await expect.element(snippet).toBeInTheDocument();
|
||||
const mark = snippet.getByRole('mark');
|
||||
await expect.element(mark).toBeInTheDocument();
|
||||
await expect.element(mark).toHaveTextContent('Brief');
|
||||
});
|
||||
|
||||
it('renders snippet as plain text when snippetOffsets is empty', async () => {
|
||||
const doc = makeDoc({ id: 'doc1' });
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: 'Er schrieb einen Brief',
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
const snippet = page.getByTestId('search-snippet');
|
||||
await expect.element(snippet).toBeInTheDocument();
|
||||
// No mark elements inside the snippet when offsets is empty
|
||||
await expect.element(snippet.getByRole('mark')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('visually marks sender when senderMatched is true', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
sender: {
|
||||
id: 'sender-1',
|
||||
firstName: 'Walter',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Walter Raddatz'
|
||||
}
|
||||
});
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: true,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
const senderMark = page.getByTestId('sender-match');
|
||||
await expect.element(senderMark).toBeInTheDocument();
|
||||
await expect.element(senderMark).toHaveTextContent('Walter Raddatz');
|
||||
});
|
||||
|
||||
it('does not mark sender when senderMatched is false', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
sender: {
|
||||
id: 'sender-1',
|
||||
firstName: 'Walter',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Walter Raddatz'
|
||||
}
|
||||
});
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
await expect.element(page.getByTestId('sender-match')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('visually marks matched receiver when their id is in matchedReceiverIds', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
receivers: [
|
||||
{ id: 'p-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
|
||||
{ id: 'p-2', firstName: 'Karl', lastName: 'Bauer', displayName: 'Karl Bauer' }
|
||||
]
|
||||
});
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: ['p-1'],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only Anna Schmidt should be marked
|
||||
const receiverMark = page.getByTestId('receiver-match');
|
||||
await expect.element(receiverMark).toBeInTheDocument();
|
||||
await expect.element(receiverMark).toHaveTextContent('Anna Schmidt');
|
||||
});
|
||||
|
||||
it('visually marks matched tag when its id is in matchedTagIds', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc1',
|
||||
tags: [
|
||||
{ id: 'tag-1', name: 'Familiengeschichte' },
|
||||
{ id: 'tag-2', name: 'Reise' }
|
||||
]
|
||||
});
|
||||
render(DocumentList, {
|
||||
...baseProps,
|
||||
documents: [doc],
|
||||
total: 1,
|
||||
matchData: {
|
||||
doc1: {
|
||||
transcriptionSnippet: undefined,
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: ['tag-1'],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
}
|
||||
}
|
||||
});
|
||||
const tagMark = page.getByTestId('tag-match');
|
||||
await expect.element(tagMark).toBeInTheDocument();
|
||||
await expect.element(tagMark).toHaveTextContent('Familiengeschichte');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ 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: [],
|
||||
|
||||
@@ -22,8 +22,23 @@ const emptyData = {
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
isDashboard: false,
|
||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||
filters: {
|
||||
q: '',
|
||||
from: '',
|
||||
to: '',
|
||||
senderId: '',
|
||||
receiverId: '',
|
||||
tags: [],
|
||||
sort: 'DATE' as const,
|
||||
dir: 'desc' as const,
|
||||
tagQ: ''
|
||||
},
|
||||
documents: [],
|
||||
total: 0,
|
||||
matchData: {} as Record<
|
||||
string,
|
||||
import('$lib/generated/api').components['schemas']['SearchMatchData']
|
||||
>,
|
||||
incompleteDocs: [],
|
||||
recentDocs: [],
|
||||
stats: null,
|
||||
|
||||
Reference in New Issue
Block a user