Compare commits
8 Commits
e4a154406e
...
b1b8fa4bed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b8fa4bed | ||
|
|
2bd5c82826 | ||
|
|
7245571ea8 | ||
|
|
b56b9dfa74 | ||
|
|
6538c9e59a | ||
|
|
c816934391 | ||
|
|
1caae38946 | ||
|
|
f2a74a6064 |
@@ -65,6 +65,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Assert no raw document date rendered via {@html} (CWE-79 — #666)
|
||||
shell: bash
|
||||
run: |
|
||||
# meta_date_raw is untrusted verbatim spreadsheet text — it must render via
|
||||
# Svelte default escaping, never {@html}. This guard flags any {@html ...}
|
||||
# whose expression references a raw-date variable. A comment mentioning
|
||||
# "{@html}" without a raw token inside the braces does NOT match.
|
||||
pattern='\{@html[^}]*(metaDateRaw|documentDateRaw|rawDate)'
|
||||
# Self-test: the regex must catch the dangerous form and ignore the comment form.
|
||||
printf '{@html doc.metaDateRaw}\n' | grep -qP "$pattern" \
|
||||
|| { echo "FAIL: guard self-test — regex missed the unsafe {@html metaDateRaw} form"; exit 1; }
|
||||
printf 'never use {@html} for this\n' | grep -qvP "$pattern" \
|
||||
|| { echo "FAIL: guard self-test — regex wrongly flagged a {@html} comment"; exit 1; }
|
||||
if grep -rPln "$pattern" --include='*.svelte' frontend/src/; then
|
||||
echo "FAIL: meta_date_raw rendered via {@html} — use default {…} escaping (CWE-79, #666)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Assert no (upload|download)-artifact past v3
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -272,6 +272,7 @@ For multipart/form-data (file uploads): bypass the typed client and use `event.f
|
||||
| Form display | German `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()` |
|
||||
| Wire format | ISO 8601 via a hidden `<input type="hidden" name="documentDate" value={dateIso}>` |
|
||||
| Display | `new Intl.DateTimeFormat('de-DE', …).format(new Date(val + 'T12:00:00'))` |
|
||||
| Honest precision display | `formatDocumentDate(iso, precision, end?, raw?, locale?)` (`$lib/shared/utils/documentDate.ts`) or the `<DocumentDate>` component — renders a document date at exactly its `meta_date_precision` (MONTH → "Juni 1916", never a fabricated day). It mirrors the Java `DocumentTitleFormatter`; both are pinned to `docs/date-label-fixtures.json` so the title and UI labels can't drift. `meta_date_raw` is untrusted — render it via default escaping, never `{@html}` (a CI guard enforces this). |
|
||||
|
||||
### Security checklist (new endpoint)
|
||||
|
||||
|
||||
@@ -378,6 +378,9 @@ public class DocumentService {
|
||||
// 1. Einfache Felder Update
|
||||
doc.setTitle(dto.getTitle());
|
||||
doc.setDocumentDate(dto.getDocumentDate());
|
||||
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
|
||||
doc.setMetaDateEnd(dto.getMetaDateEnd());
|
||||
doc.setMetaDateRaw(dto.getMetaDateRaw());
|
||||
doc.setLocation(dto.getLocation());
|
||||
doc.setTranscription(dto.getTranscription());
|
||||
doc.setSummary(dto.getSummary());
|
||||
|
||||
@@ -159,7 +159,13 @@ public class DocumentImporter {
|
||||
Person sender = resolveSender(row.get("sender_person_id"), senderName);
|
||||
Set<Person> receivers = resolveReceivers(row.get("receiver_person_ids"));
|
||||
|
||||
doc.setTitle(index);
|
||||
LocalDate date = parseIsoDate(row.get("date_iso"));
|
||||
DatePrecision precision = parsePrecision(row.get("date_precision"));
|
||||
LocalDate dateEnd = parseIsoDate(row.get("date_end"));
|
||||
String dateRaw = blankToNull(row.get("date_raw"));
|
||||
String location = blankToNull(row.get("location"));
|
||||
|
||||
doc.setTitle(buildTitle(index, date, precision, dateEnd, dateRaw, location));
|
||||
doc.setStatus(status);
|
||||
doc.setFilePath(s3Key);
|
||||
doc.setContentType(contentType);
|
||||
@@ -171,17 +177,31 @@ public class DocumentImporter {
|
||||
doc.getReceivers().clear();
|
||||
doc.getReceivers().addAll(receivers);
|
||||
doc.setReceiverText(blankToNull(receiverNames));
|
||||
doc.setDocumentDate(parseIsoDate(row.get("date_iso")));
|
||||
doc.setMetaDatePrecision(parsePrecision(row.get("date_precision")));
|
||||
doc.setMetaDateEnd(parseIsoDate(row.get("date_end")));
|
||||
doc.setMetaDateRaw(blankToNull(row.get("date_raw")));
|
||||
doc.setLocation(blankToNull(row.get("location")));
|
||||
doc.setDocumentDate(date);
|
||||
doc.setMetaDatePrecision(precision);
|
||||
doc.setMetaDateEnd(dateEnd);
|
||||
doc.setMetaDateRaw(dateRaw);
|
||||
doc.setLocation(location);
|
||||
doc.setSummary(blankToNull(row.get("summary")));
|
||||
attachTag(doc, row.get("tags"));
|
||||
doc.setMetadataComplete(doc.getDocumentDate() != null || sender != null || !receivers.isEmpty());
|
||||
return doc;
|
||||
}
|
||||
|
||||
// The title carries the date at the HONEST precision (never a fabricated day) via the
|
||||
// shared DocumentTitleFormatter, plus the location — kept under 20 lines by delegating.
|
||||
private static String buildTitle(String index, LocalDate date, DatePrecision precision,
|
||||
LocalDate end, String raw, String location) {
|
||||
StringBuilder title = new StringBuilder(index);
|
||||
if (date != null && precision != DatePrecision.UNKNOWN) {
|
||||
title.append(" – ").append(DocumentTitleFormatter.formatTitleDate(date, precision, end, raw));
|
||||
}
|
||||
if (location != null && !location.isBlank()) {
|
||||
title.append(" – ").append(location);
|
||||
}
|
||||
return title.toString();
|
||||
}
|
||||
|
||||
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
||||
|
||||
private Person resolveSender(String slug, String rawName) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Produces the honest German date label baked into an import title — at exactly
|
||||
* the precision the data claims, never finer. This is the Java half of the
|
||||
* single source of truth shared with the frontend {@code formatDocumentDate}
|
||||
* (TypeScript): both are asserted against {@code docs/date-label-fixtures.json}
|
||||
* so the two implementations cannot drift (see #666).
|
||||
*
|
||||
* <p>Import titles are always German, so the labels here are the German
|
||||
* canonical form (mirroring the {@code de} Paraglide messages used by the UI).
|
||||
*/
|
||||
final class DocumentTitleFormatter {
|
||||
|
||||
private static final DateTimeFormatter LONG = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN);
|
||||
private static final DateTimeFormatter MONTH_YEAR = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.GERMAN);
|
||||
private static final DateTimeFormatter MEDIUM = DateTimeFormatter.ofPattern("d. MMM yyyy", Locale.GERMAN);
|
||||
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("d. MMM", Locale.GERMAN);
|
||||
|
||||
private static final String UNKNOWN = "Datum unbekannt";
|
||||
private static final String APPROX_PREFIX = "ca.";
|
||||
private static final String OPEN_RANGE_PREFIX = "ab";
|
||||
|
||||
private DocumentTitleFormatter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param date the sort/filter anchor day; null for UNKNOWN rows
|
||||
* @param precision descriptive precision metadata
|
||||
* @param end the RANGE end day; null means an open-ended range
|
||||
* @param raw the verbatim spreadsheet cell, used only to pick a season word
|
||||
* @return the honest German label
|
||||
*/
|
||||
static String formatTitleDate(LocalDate date, DatePrecision precision, LocalDate end, String raw) {
|
||||
if (precision == DatePrecision.UNKNOWN || date == null) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
return switch (precision) {
|
||||
case DAY -> LONG.format(date);
|
||||
case MONTH -> MONTH_YEAR.format(date);
|
||||
case SEASON -> seasonLabel(date, raw);
|
||||
case YEAR -> String.valueOf(date.getYear());
|
||||
case APPROX -> APPROX_PREFIX + " " + date.getYear();
|
||||
case RANGE -> rangeLabel(date, end);
|
||||
case UNKNOWN -> UNKNOWN;
|
||||
};
|
||||
}
|
||||
|
||||
private static String seasonLabel(LocalDate date, String raw) {
|
||||
Season season = seasonFromRaw(raw);
|
||||
if (season == null) {
|
||||
season = seasonOfMonth(date.getMonthValue());
|
||||
}
|
||||
return season.german + " " + date.getYear();
|
||||
}
|
||||
|
||||
private static String rangeLabel(LocalDate start, LocalDate end) {
|
||||
if (end == null) {
|
||||
return OPEN_RANGE_PREFIX + " " + MEDIUM.format(start);
|
||||
}
|
||||
if (end.equals(start)) {
|
||||
return MEDIUM.format(start);
|
||||
}
|
||||
if (start.getYear() != end.getYear()) {
|
||||
return MEDIUM.format(start) + " – " + MEDIUM.format(end);
|
||||
}
|
||||
if (start.getMonthValue() == end.getMonthValue()) {
|
||||
return start.getDayOfMonth() + ".–" + MEDIUM.format(end);
|
||||
}
|
||||
return DAY_MONTH.format(start) + " – " + MEDIUM.format(end);
|
||||
}
|
||||
|
||||
// ─── season mapping — mirrors the normalizer's representative months ─────────────
|
||||
|
||||
private enum Season {
|
||||
SPRING("Frühling"),
|
||||
SUMMER("Sommer"),
|
||||
AUTUMN("Herbst"),
|
||||
WINTER("Winter");
|
||||
|
||||
private final String german;
|
||||
|
||||
Season(String german) {
|
||||
this.german = german;
|
||||
}
|
||||
}
|
||||
|
||||
private static Season seasonOfMonth(int month) {
|
||||
if (month >= 3 && month <= 5) return Season.SPRING;
|
||||
if (month >= 6 && month <= 8) return Season.SUMMER;
|
||||
if (month >= 9 && month <= 11) return Season.AUTUMN;
|
||||
return Season.WINTER;
|
||||
}
|
||||
|
||||
private static Season seasonFromRaw(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
String token = raw.trim().split("\\s+")[0].toLowerCase(Locale.GERMAN);
|
||||
return switch (token) {
|
||||
case "frühling", "frühjahr" -> Season.SPRING;
|
||||
case "sommer" -> Season.SUMMER;
|
||||
case "herbst" -> Season.AUTUMN;
|
||||
case "winter" -> Season.WINTER;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,26 @@ class DocumentServiceTest {
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_persistsDatePrecisionEndAndRaw() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setDocumentDate(LocalDate.of(1917, 1, 10));
|
||||
dto.setMetaDatePrecision(DatePrecision.RANGE);
|
||||
dto.setMetaDateEnd(LocalDate.of(1917, 1, 11));
|
||||
dto.setMetaDateRaw("10.–11. Januar 1917");
|
||||
|
||||
documentService.updateDocument(id, dto, null, null);
|
||||
|
||||
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
|
||||
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
|
||||
assertThat(doc.getMetaDateRaw()).isEqualTo("10.–11. Januar 1917");
|
||||
}
|
||||
|
||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -404,6 +404,50 @@ class DocumentImporterTest {
|
||||
d.getReceivers().isEmpty() && d.getTags().isEmpty()));
|
||||
}
|
||||
|
||||
// ─── title carries the honest date label — never a precision the data lacks ───────
|
||||
|
||||
@Test
|
||||
void load_buildsTitleWithMonthLabel_whenPrecisionIsMonth(@TempDir Path tempDir) throws Exception {
|
||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||
when(documentService.findByOriginalFilename("W-0100")).thenReturn(Optional.empty());
|
||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
Path xlsx = writeDocs(tempDir, docRow("W-0100", "", "", "", "", "",
|
||||
"1916-06-01", "Juni 1916", "MONTH", ""));
|
||||
|
||||
importer.load(xlsx.toFile());
|
||||
|
||||
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||
d.getTitle().contains("Juni 1916") && !d.getTitle().contains("1. Juni")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_buildsTitleWithFullDate_whenPrecisionIsDay(@TempDir Path tempDir) throws Exception {
|
||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||
when(documentService.findByOriginalFilename("W-0101")).thenReturn(Optional.empty());
|
||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
Path xlsx = writeDocs(tempDir, docRow("W-0101", "", "", "", "", "",
|
||||
"1943-12-24", "24.12.1943", "DAY", ""));
|
||||
|
||||
importer.load(xlsx.toFile());
|
||||
|
||||
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||
d.getTitle().contains("24. Dezember 1943")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_buildsTitleFromIndexOnly_whenDateUnknown(@TempDir Path tempDir) throws Exception {
|
||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||
when(documentService.findByOriginalFilename("W-0102")).thenReturn(Optional.empty());
|
||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
Path xlsx = writeDocs(tempDir, docRow("W-0102", "", "", "", "", "",
|
||||
"", "?", "UNKNOWN", ""));
|
||||
|
||||
importer.load(xlsx.toFile());
|
||||
|
||||
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||
d.getTitle().equals("W-0102")));
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private Map<String, String> docRow(String index, String file, String senderId, String senderName,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Asserts the Java title label against the SAME shared fixture table the TS
|
||||
* formatter spec uses ({@code docs/date-label-fixtures.json}). This is the
|
||||
* drift guard requested in #666 review: the two label implementations cannot
|
||||
* silently diverge (en-dash vs hyphen, "ca." vs "circa", season words, range
|
||||
* collapse) because both are pinned to one committed rule set.
|
||||
*/
|
||||
class DocumentTitleFormatterTest {
|
||||
|
||||
@TestFactory
|
||||
List<DynamicTest> matchesSharedFixtureTable() throws Exception {
|
||||
// Maven runs tests from the backend/ module dir; the fixture lives at repo-root docs/.
|
||||
Path fixture = Path.of("..", "docs", "date-label-fixtures.json");
|
||||
JsonNode root = new ObjectMapper().readTree(Files.readString(fixture));
|
||||
List<DynamicTest> tests = new ArrayList<>();
|
||||
for (JsonNode c : root.get("cases")) {
|
||||
String name = c.get("name").asText();
|
||||
LocalDate anchor = parseDate(c.get("anchor"));
|
||||
DatePrecision precision = DatePrecision.valueOf(c.get("precision").asText());
|
||||
LocalDate end = parseDate(c.get("end"));
|
||||
String raw = c.get("raw").isNull() ? null : c.get("raw").asText();
|
||||
String expected = c.get("expected").asText();
|
||||
tests.add(DynamicTest.dynamicTest(name, () ->
|
||||
assertThat(DocumentTitleFormatter.formatTitleDate(anchor, precision, end, raw))
|
||||
.isEqualTo(expected)));
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(JsonNode node) {
|
||||
return node == null || node.isNull() ? null : LocalDate.parse(node.asText());
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(tagTreeLoader, "TagTreeImporter", "Spring Component", "Upserts the tag hierarchy from canonical-tag-tree.xlsx via TagService (by canonical tag_path).")
|
||||
Component(personRegLoader, "PersonRegisterImporter", "Spring Component", "Upserts register persons from canonical-persons.xlsx via PersonService (by normalizer person_id).")
|
||||
Component(personTreeLoader, "PersonTreeImporter", "Spring Component", "Upserts tree persons + relationships from canonical-persons-tree.json via PersonService and RelationshipService.")
|
||||
Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, keeps the S3 upload + thumbnail plumbing, and ports the path-traversal / homoglyph / absolute-path / %PDF magic-byte security guards.")
|
||||
Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, builds an honest precision-aware title via DocumentTitleFormatter, keeps the S3 upload + thumbnail plumbing, and ports the path-traversal / homoglyph / absolute-path / %PDF magic-byte security guards.")
|
||||
Component(titleFmt, "DocumentTitleFormatter", "Pure helper", "Formats the date label baked into an import title at exactly the data's precision (MONTH -> 'Juni 1916', never a fabricated day). Mirrors the frontend formatDocumentDate; both are pinned to docs/date-label-fixtures.json (#666).")
|
||||
Component(sheetReader, "CanonicalSheetReader", "POI helper", "Maps a canonical .xlsx by header name (no positional indices), splits pipe-delimited list columns, fails closed (IMPORT_ARTIFACT_INVALID) on a missing required header.")
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
@@ -43,6 +44,7 @@ Rel(importOrch, docLoader, "4. Loads documents")
|
||||
Rel(tagTreeLoader, sheetReader, "Reads canonical .xlsx")
|
||||
Rel(personRegLoader, sheetReader, "Reads canonical .xlsx")
|
||||
Rel(docLoader, sheetReader, "Reads canonical .xlsx")
|
||||
Rel(docLoader, titleFmt, "Builds honest title date")
|
||||
Rel(tagTreeLoader, tagSvc, "Upserts tags by source_ref")
|
||||
Rel(personRegLoader, personSvc, "Upserts persons by source_ref")
|
||||
Rel(personTreeLoader, personSvc, "Upserts persons by source_ref")
|
||||
|
||||
101
docs/date-label-fixtures.json
Normal file
101
docs/date-label-fixtures.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"_comment": "Single source of truth for the honest date-label rule set shared by the TS formatDocumentDate (frontend/src/lib/shared/utils/documentDate.ts) and the Java formatTitleDate (backend importing/DocumentTitleFormatter.java). Both test suites assert against THIS table so the two implementations cannot drift (en-dash vs hyphen, 'ca.' vs 'circa', season words, range collapse). Expected labels are the GERMAN (de) canonical form: import titles are always German, and the TS formatter defaults to the de locale. Do not edit one side's expectation without editing this file and both tests. See issue #666 and the Markus/Sara drift-guard decision.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "DAY renders a full long date",
|
||||
"precision": "DAY",
|
||||
"anchor": "1943-12-24",
|
||||
"end": null,
|
||||
"raw": null,
|
||||
"expected": "24. Dezember 1943"
|
||||
},
|
||||
{
|
||||
"name": "MONTH renders month and year only — never a fabricated day",
|
||||
"precision": "MONTH",
|
||||
"anchor": "1916-06-01",
|
||||
"end": null,
|
||||
"raw": "Juni 1916",
|
||||
"expected": "Juni 1916"
|
||||
},
|
||||
{
|
||||
"name": "SEASON renders the season word from raw",
|
||||
"precision": "SEASON",
|
||||
"anchor": "1916-06-01",
|
||||
"end": null,
|
||||
"raw": "Sommer 1916",
|
||||
"expected": "Sommer 1916"
|
||||
},
|
||||
{
|
||||
"name": "SEASON with null raw derives the season from the anchor month",
|
||||
"precision": "SEASON",
|
||||
"anchor": "1916-04-01",
|
||||
"end": null,
|
||||
"raw": null,
|
||||
"expected": "Frühling 1916"
|
||||
},
|
||||
{
|
||||
"name": "YEAR renders the year only — suppresses month and day",
|
||||
"precision": "YEAR",
|
||||
"anchor": "1916-06-15",
|
||||
"end": null,
|
||||
"raw": null,
|
||||
"expected": "1916"
|
||||
},
|
||||
{
|
||||
"name": "APPROX renders a ca. prefix before the year",
|
||||
"precision": "APPROX",
|
||||
"anchor": "1920-01-01",
|
||||
"end": null,
|
||||
"raw": null,
|
||||
"expected": "ca. 1920"
|
||||
},
|
||||
{
|
||||
"name": "RANGE in the same month collapses the shared month and year",
|
||||
"precision": "RANGE",
|
||||
"anchor": "1917-01-10",
|
||||
"end": "1917-01-11",
|
||||
"raw": null,
|
||||
"expected": "10.–11. Jan. 1917"
|
||||
},
|
||||
{
|
||||
"name": "RANGE across months expands both months, sharing the year",
|
||||
"precision": "RANGE",
|
||||
"anchor": "1917-01-30",
|
||||
"end": "1917-02-02",
|
||||
"raw": null,
|
||||
"expected": "30. Jan. – 2. Feb. 1917"
|
||||
},
|
||||
{
|
||||
"name": "RANGE across a year boundary expands both full dates",
|
||||
"precision": "RANGE",
|
||||
"anchor": "1916-12-30",
|
||||
"end": "1917-01-02",
|
||||
"raw": null,
|
||||
"expected": "30. Dez. 1916 – 2. Jan. 1917"
|
||||
},
|
||||
{
|
||||
"name": "RANGE where end equals start collapses to a single day",
|
||||
"precision": "RANGE",
|
||||
"anchor": "1917-01-10",
|
||||
"end": "1917-01-10",
|
||||
"raw": null,
|
||||
"expected": "10. Jan. 1917"
|
||||
},
|
||||
{
|
||||
"name": "RANGE with a null end renders an open-range indicator, never a fabricated end",
|
||||
"precision": "RANGE",
|
||||
"anchor": "1917-01-10",
|
||||
"end": null,
|
||||
"raw": null,
|
||||
"expected": "ab 10. Jan. 1917"
|
||||
},
|
||||
{
|
||||
"name": "UNKNOWN renders the unknown label regardless of anchor",
|
||||
"precision": "UNKNOWN",
|
||||
"anchor": null,
|
||||
"end": null,
|
||||
"raw": "?",
|
||||
"expected": "Datum unbekannt"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -261,6 +261,24 @@
|
||||
"doc_preview_iframe_title": "Dokumentvorschau",
|
||||
"doc_image_alt": "Original-Scan",
|
||||
"doc_no_date": "Kein Datum",
|
||||
"date_precision_unknown": "Datum unbekannt",
|
||||
"date_precision_approx_prefix": "ca.",
|
||||
"date_range_open_prefix": "ab",
|
||||
"date_season_spring": "Frühling",
|
||||
"date_season_summer": "Sommer",
|
||||
"date_season_autumn": "Herbst",
|
||||
"date_season_winter": "Winter",
|
||||
"date_original_label": "Originaltext:",
|
||||
"date_unknown_icon_label": "Datum unbekannt",
|
||||
"form_label_date_precision": "Datumsgenauigkeit",
|
||||
"form_label_date_end": "Enddatum",
|
||||
"date_precision_option_day": "Genauer Tag",
|
||||
"date_precision_option_month": "Monat",
|
||||
"date_precision_option_season": "Jahreszeit",
|
||||
"date_precision_option_year": "Jahr",
|
||||
"date_precision_option_range": "Zeitraum",
|
||||
"date_precision_option_approx": "Ungefähr",
|
||||
"date_precision_option_unknown": "Unbekannt",
|
||||
"person_merge_will_be_deleted": "wird gelöscht.",
|
||||
"comp_typeahead_placeholder": "Namen tippen...",
|
||||
"comp_typeahead_loading": "Suche...",
|
||||
|
||||
@@ -261,6 +261,24 @@
|
||||
"doc_preview_iframe_title": "Document Preview",
|
||||
"doc_image_alt": "Original scan",
|
||||
"doc_no_date": "No date",
|
||||
"date_precision_unknown": "Date unknown",
|
||||
"date_precision_approx_prefix": "c.",
|
||||
"date_range_open_prefix": "from",
|
||||
"date_season_spring": "Spring",
|
||||
"date_season_summer": "Summer",
|
||||
"date_season_autumn": "Autumn",
|
||||
"date_season_winter": "Winter",
|
||||
"date_original_label": "Original:",
|
||||
"date_unknown_icon_label": "Date unknown",
|
||||
"form_label_date_precision": "Date precision",
|
||||
"form_label_date_end": "End date",
|
||||
"date_precision_option_day": "Exact day",
|
||||
"date_precision_option_month": "Month",
|
||||
"date_precision_option_season": "Season",
|
||||
"date_precision_option_year": "Year",
|
||||
"date_precision_option_range": "Range",
|
||||
"date_precision_option_approx": "Approximate",
|
||||
"date_precision_option_unknown": "Unknown",
|
||||
"person_merge_will_be_deleted": "will be deleted.",
|
||||
"comp_typeahead_placeholder": "Type a name...",
|
||||
"comp_typeahead_loading": "Searching...",
|
||||
|
||||
@@ -261,6 +261,24 @@
|
||||
"doc_preview_iframe_title": "Vista previa del documento",
|
||||
"doc_image_alt": "Escaneado original",
|
||||
"doc_no_date": "Sin fecha",
|
||||
"date_precision_unknown": "Fecha desconocida",
|
||||
"date_precision_approx_prefix": "ca.",
|
||||
"date_range_open_prefix": "desde",
|
||||
"date_season_spring": "Primavera",
|
||||
"date_season_summer": "Verano",
|
||||
"date_season_autumn": "Otoño",
|
||||
"date_season_winter": "Invierno",
|
||||
"date_original_label": "Texto original:",
|
||||
"date_unknown_icon_label": "Fecha desconocida",
|
||||
"form_label_date_precision": "Precisión de la fecha",
|
||||
"form_label_date_end": "Fecha final",
|
||||
"date_precision_option_day": "Día exacto",
|
||||
"date_precision_option_month": "Mes",
|
||||
"date_precision_option_season": "Estación",
|
||||
"date_precision_option_year": "Año",
|
||||
"date_precision_option_range": "Periodo",
|
||||
"date_precision_option_approx": "Aproximada",
|
||||
"date_precision_option_unknown": "Desconocida",
|
||||
"person_merge_will_be_deleted": "será eliminado.",
|
||||
"comp_typeahead_placeholder": "Escriba un nombre...",
|
||||
"comp_typeahead_loading": "Buscando...",
|
||||
|
||||
60
frontend/src/lib/document/DocumentDate.svelte
Normal file
60
frontend/src/lib/document/DocumentDate.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
iso?: string | null;
|
||||
precision?: DatePrecision | null;
|
||||
end?: string | null;
|
||||
raw?: string | null;
|
||||
/** Show the verbatim "Originaltext: …" secondary line when raw is present. */
|
||||
showRaw?: boolean;
|
||||
};
|
||||
|
||||
let { iso = null, precision = null, end = null, raw = null, showRaw = true }: Props = $props();
|
||||
|
||||
const effectivePrecision = $derived<DatePrecision>(precision ?? (iso ? 'DAY' : 'UNKNOWN'));
|
||||
const label = $derived(formatDocumentDate(iso, effectivePrecision, end, raw, getLocale()));
|
||||
const isUnknown = $derived(effectivePrecision === 'UNKNOWN' || !iso);
|
||||
// Only show the verbatim raw line where it adds information the label can't: the
|
||||
// season word's source, or the original cell behind an "unknown"/approx date.
|
||||
const showRawLine = $derived(
|
||||
showRaw &&
|
||||
!!raw &&
|
||||
raw.trim().length > 0 &&
|
||||
(isUnknown || effectivePrecision === 'SEASON' || effectivePrecision === 'APPROX')
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="inline-flex flex-col">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{#if isUnknown}
|
||||
<!-- Non-color cue (WCAG 1.4.1): a calendar-with-question glyph. The visible
|
||||
"Datum unbekannt" text is the redundant textual cue, so the icon is
|
||||
decorative and hidden from assistive tech (per Leonie's a11y note). -->
|
||||
<svg
|
||||
class="h-3.5 w-3.5 shrink-0 text-ink-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="M9 16a1.5 1.5 0 0 1 3 0c0 1-1.5 1.2-1.5 2.2" />
|
||||
<path d="M10.5 21h.01" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
{#if showRawLine}
|
||||
<!-- Visible secondary line (WCAG 1.4.13 — not tooltip-only). raw is untrusted
|
||||
verbatim spreadsheet text; rendered via default Svelte interpolation, which
|
||||
HTML-escapes it (never {@html}; CWE-79). -->
|
||||
<span class="font-sans text-xs text-ink-2">{m.date_original_label()} {raw}</span>
|
||||
{/if}
|
||||
</span>
|
||||
35
frontend/src/lib/document/DocumentDate.svelte.test.ts
Normal file
35
frontend/src/lib/document/DocumentDate.svelte.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentDate from './DocumentDate.svelte';
|
||||
|
||||
// Browser-project (Playwright) tests — CI only.
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('DocumentDate', () => {
|
||||
it('renders a DAY date as a full long date', async () => {
|
||||
render(DocumentDate, { props: { iso: '1943-12-24', precision: 'DAY' } });
|
||||
await expect.element(page.getByText('24. Dezember 1943')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders MONTH precision as month + year, never a day', async () => {
|
||||
render(DocumentDate, { props: { iso: '1916-06-01', precision: 'MONTH', raw: 'Juni 1916' } });
|
||||
await expect.element(page.getByText('Juni 1916')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the verbatim raw cell as a visible secondary line for UNKNOWN (not tooltip-only)', async () => {
|
||||
render(DocumentDate, { props: { iso: null, precision: 'UNKNOWN', raw: 'Sommer?' } });
|
||||
// Real, visible text — not hidden behind a title attribute.
|
||||
await expect.element(page.getByText('Datum unbekannt')).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Sommer\?/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a malicious raw value as inert escaped text (no element injected)', async () => {
|
||||
const malicious = '<img src=x onerror="alert(1)">';
|
||||
render(DocumentDate, { props: { iso: null, precision: 'UNKNOWN', raw: malicious } });
|
||||
// The payload appears as literal text, and no <img> is created in the DOM.
|
||||
await expect.element(page.getByText(/<img/)).toBeInTheDocument();
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import WhoWhenSection from '$lib/document/WhoWhenSection.svelte';
|
||||
import DescriptionSection from '$lib/document/DescriptionSection.svelte';
|
||||
import type { Tag } from '$lib/tag/TagInput.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type Doc = components['schemas']['Document'];
|
||||
@@ -26,6 +27,8 @@ let {
|
||||
senderId = $bindable(''),
|
||||
selectedReceivers = $bindable<Person[]>([]),
|
||||
dateIso = $bindable(''),
|
||||
datePrecision = $bindable<DatePrecision>('DAY'),
|
||||
dateEndIso = $bindable(''),
|
||||
currentTitle = $bindable(''),
|
||||
topbar,
|
||||
actionbar
|
||||
@@ -38,6 +41,8 @@ let {
|
||||
senderId?: string;
|
||||
selectedReceivers?: Person[];
|
||||
dateIso?: string;
|
||||
datePrecision?: DatePrecision;
|
||||
dateEndIso?: string;
|
||||
currentTitle?: string;
|
||||
topbar: Snippet;
|
||||
actionbar: Snippet;
|
||||
@@ -47,6 +52,8 @@ tags = untrack(() => (doc.tags as Tag[]) ?? []);
|
||||
senderId = untrack(() => doc.sender?.id ?? '');
|
||||
selectedReceivers = untrack(() => (doc.receivers as Person[]) ?? []);
|
||||
dateIso = untrack(() => doc.documentDate ?? '');
|
||||
datePrecision = untrack(() => doc.metaDatePrecision ?? (doc.documentDate ? 'DAY' : 'UNKNOWN'));
|
||||
dateEndIso = untrack(() => doc.metaDateEnd ?? '');
|
||||
currentTitle = untrack(() => doc.title ?? '');
|
||||
|
||||
const fileLoader = createFileLoader();
|
||||
@@ -199,6 +206,9 @@ async function handleReplaceFile(e: Event) {
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
bind:precision={datePrecision}
|
||||
bind:endDateIso={dateEndIso}
|
||||
rawDate={doc.metaDateRaw ?? ''}
|
||||
initialDateIso={doc.documentDate ?? ''}
|
||||
initialLocation={doc.location ?? ''}
|
||||
initialSenderName={doc.sender?.displayName ?? ''}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { formatDate } from '$lib/shared/utils/date';
|
||||
import { formatDocumentStatus } from '$lib/document/documentStatusLabel';
|
||||
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||
import RelationshipPill from '$lib/person/relationship/RelationshipPill.svelte';
|
||||
import DocumentDate from './DocumentDate.svelte';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
@@ -16,6 +18,9 @@ type GeschichteSummary = {
|
||||
|
||||
type Props = {
|
||||
documentDate: string | null;
|
||||
metaDatePrecision?: DatePrecision | null;
|
||||
metaDateEnd?: string | null;
|
||||
metaDateRaw?: string | null;
|
||||
location: string | null;
|
||||
status: string;
|
||||
sender: Person | null;
|
||||
@@ -29,6 +34,9 @@ type Props = {
|
||||
|
||||
let {
|
||||
documentDate,
|
||||
metaDatePrecision = null,
|
||||
metaDateEnd = null,
|
||||
metaDateRaw = null,
|
||||
location,
|
||||
status,
|
||||
sender,
|
||||
@@ -59,7 +67,6 @@ function formatGeschichteDate(g: GeschichteSummary): string {
|
||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||
}
|
||||
|
||||
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
|
||||
const displayLocation = $derived(location ?? '—');
|
||||
const statusLabel = $derived(formatDocumentStatus(status));
|
||||
const visibleReceivers = $derived(receivers.slice(0, VISIBLE_RECEIVER_LIMIT));
|
||||
@@ -105,7 +112,18 @@ function getFullName(person: Person): string {
|
||||
<dl class="space-y-3 font-serif text-sm">
|
||||
<div>
|
||||
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_date()}</dt>
|
||||
<dd class="text-ink">{formattedDate}</dd>
|
||||
<dd class="text-ink">
|
||||
{#if documentDate || metaDateRaw}
|
||||
<DocumentDate
|
||||
iso={documentDate}
|
||||
precision={metaDatePrecision}
|
||||
end={metaDateEnd}
|
||||
raw={metaDateRaw}
|
||||
/>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-sans text-xs font-medium text-ink-3">{m.form_label_location()}</dt>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type Document = components['schemas']['Document'];
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
@@ -49,7 +50,9 @@ function handleInput() {
|
||||
const docs = body.items.map((it) => ({
|
||||
id: it.id,
|
||||
title: it.title,
|
||||
documentDate: it.documentDate
|
||||
documentDate: it.documentDate,
|
||||
metaDatePrecision: it.metaDatePrecision,
|
||||
metaDateEnd: it.metaDateEnd
|
||||
})) as unknown as Document[];
|
||||
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
||||
}
|
||||
@@ -73,8 +76,10 @@ function removeDocument(id: string | undefined) {
|
||||
}
|
||||
|
||||
function formatDocLabel(doc: Document): string {
|
||||
if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`;
|
||||
return doc.title;
|
||||
if (!doc.documentDate) return doc.title;
|
||||
const precision = (doc.metaDatePrecision as DatePrecision | undefined) ?? 'DAY';
|
||||
const label = formatDocumentDate(doc.documentDate, precision, doc.metaDateEnd, null, getLocale());
|
||||
return `${doc.title} · ${label}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const docFactory = (id: string, title: string, date = '1880-01-01') => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: date,
|
||||
metaDatePrecision: 'DAY' as const,
|
||||
originalFilename: `${title}.pdf`,
|
||||
receivers: [],
|
||||
tags: [],
|
||||
@@ -55,7 +56,8 @@ describe('DocumentMultiSelect — rendering', () => {
|
||||
selectedDocuments: [docFactory('d1', 'Brief vom 1. Mai', '1882-05-01')]
|
||||
});
|
||||
await expect.element(page.getByText(/Brief vom 1\. Mai/)).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/01\.05\.1882/)).toBeInTheDocument();
|
||||
// DAY precision renders the honest long date (formatDocumentDate), not 01.05.1882.
|
||||
await expect.element(page.getByText(/1\. Mai 1882/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits a hidden documentIds input for each pre-selected document', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { applyOffsets } from '$lib/document/search';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import DocumentDate from './DocumentDate.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||
import ProgressRing from '$lib/shared/primitives/ProgressRing.svelte';
|
||||
@@ -164,7 +164,16 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
<!-- Mobile-only metadata -->
|
||||
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 font-sans text-xs text-ink-2 sm:hidden">
|
||||
<div>
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
{#if doc.documentDate}
|
||||
<DocumentDate
|
||||
iso={doc.documentDate}
|
||||
precision={doc.metaDatePrecision}
|
||||
end={doc.metaDateEnd}
|
||||
showRaw={false}
|
||||
/>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<ProgressRing percentage={item.completionPercentage} />
|
||||
@@ -178,7 +187,16 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
<!-- Right column — desktop only -->
|
||||
<div class="hidden flex-col gap-2 pl-4 font-sans text-sm text-ink-2 sm:flex sm:w-44 lg:w-56">
|
||||
<div>
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
{#if doc.documentDate}
|
||||
<DocumentDate
|
||||
iso={doc.documentDate}
|
||||
precision={doc.metaDatePrecision}
|
||||
end={doc.metaDateEnd}
|
||||
showRaw={false}
|
||||
/>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
|
||||
title: 'Testbrief',
|
||||
originalFilename: 'testbrief.pdf',
|
||||
documentDate: '2024-03-15',
|
||||
metaDatePrecision: 'DAY',
|
||||
sender: undefined,
|
||||
receivers: [],
|
||||
tags: [],
|
||||
|
||||
@@ -49,6 +49,7 @@ const baseItem = (overrides: Record<string, unknown> = {}) => ({
|
||||
title: 'Brief 1923',
|
||||
originalFilename: 'b.pdf',
|
||||
documentDate: '1923-04-15',
|
||||
metaDatePrecision: 'DAY' as const,
|
||||
sender,
|
||||
receivers: [receiver],
|
||||
tags: [],
|
||||
|
||||
@@ -8,6 +8,7 @@ import DocumentTopBarTitle from './DocumentTopBarTitle.svelte';
|
||||
import DocumentTopBarActions from './DocumentTopBarActions.svelte';
|
||||
import DocumentMobileMenu from './DocumentMobileMenu.svelte';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
@@ -17,6 +18,9 @@ type Doc = {
|
||||
title?: string | null;
|
||||
originalFilename?: string | null;
|
||||
documentDate?: string | null;
|
||||
metaDatePrecision?: DatePrecision | null;
|
||||
metaDateEnd?: string | null;
|
||||
metaDateRaw?: string | null;
|
||||
sender?: Person | null;
|
||||
receivers?: Person[] | null;
|
||||
filePath?: string | null;
|
||||
@@ -81,6 +85,9 @@ const overflowPersons = $derived(receivers.slice(2));
|
||||
title={doc.title}
|
||||
originalFilename={doc.originalFilename}
|
||||
documentDate={doc.documentDate}
|
||||
metaDatePrecision={doc.metaDatePrecision}
|
||||
metaDateEnd={doc.metaDateEnd}
|
||||
metaDateRaw={doc.metaDateRaw}
|
||||
/>
|
||||
|
||||
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
|
||||
@@ -151,6 +158,9 @@ const overflowPersons = $derived(receivers.slice(2));
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<DocumentMetadataDrawer
|
||||
documentDate={doc.documentDate ?? null}
|
||||
metaDatePrecision={doc.metaDatePrecision ?? null}
|
||||
metaDateEnd={doc.metaDateEnd ?? null}
|
||||
metaDateRaw={doc.metaDateRaw ?? null}
|
||||
location={doc.location ?? null}
|
||||
status={doc.status ?? 'PLACEHOLDER'}
|
||||
sender={doc.sender ?? null}
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type Props = {
|
||||
title?: string | null;
|
||||
originalFilename?: string | null;
|
||||
documentDate?: string | null;
|
||||
metaDatePrecision?: DatePrecision | null;
|
||||
metaDateEnd?: string | null;
|
||||
metaDateRaw?: string | null;
|
||||
};
|
||||
|
||||
let { title, originalFilename, documentDate }: Props = $props();
|
||||
let {
|
||||
title,
|
||||
originalFilename,
|
||||
documentDate,
|
||||
metaDatePrecision = null,
|
||||
metaDateEnd = null,
|
||||
metaDateRaw = null
|
||||
}: Props = $props();
|
||||
|
||||
const displayTitle = $derived(title || originalFilename || '');
|
||||
const shortDate = $derived(documentDate ? formatDate(documentDate, 'short') : null);
|
||||
const longDate = $derived(documentDate ? formatDate(documentDate, 'long') : null);
|
||||
const precision = $derived<DatePrecision>(metaDatePrecision ?? (documentDate ? 'DAY' : 'UNKNOWN'));
|
||||
const dateLabel = $derived(
|
||||
documentDate
|
||||
? formatDocumentDate(documentDate, precision, metaDateEnd, metaDateRaw, getLocale())
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-hidden">
|
||||
@@ -21,10 +36,7 @@ const longDate = $derived(documentDate ? formatDate(documentDate, 'long') : null
|
||||
>
|
||||
{displayTitle}
|
||||
</h1>
|
||||
{#if shortDate}
|
||||
<p class="font-sans text-[16px] text-ink-2">
|
||||
<span class="lg:hidden">{shortDate}</span>
|
||||
<span class="hidden lg:inline">{longDate}</span>
|
||||
</p>
|
||||
{#if dateLabel}
|
||||
<p class="font-sans text-[16px] text-ink-2">{dateLabel}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import FieldLabelBadge from '$lib/shared/primitives/FieldLabelBadge.svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
@@ -13,6 +14,9 @@ let {
|
||||
senderId = $bindable(''),
|
||||
selectedReceivers = $bindable<Person[]>([]),
|
||||
dateIso = $bindable(''),
|
||||
precision = $bindable<DatePrecision>('DAY'),
|
||||
endDateIso = $bindable(''),
|
||||
rawDate = '',
|
||||
initialDateIso = '',
|
||||
initialLocation = '',
|
||||
initialSenderName = '',
|
||||
@@ -24,6 +28,9 @@ let {
|
||||
senderId?: string;
|
||||
selectedReceivers?: Person[];
|
||||
dateIso?: string;
|
||||
precision?: DatePrecision;
|
||||
endDateIso?: string;
|
||||
rawDate?: string;
|
||||
initialDateIso?: string;
|
||||
initialLocation?: string;
|
||||
initialSenderName?: string;
|
||||
@@ -33,11 +40,24 @@ let {
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
const PRECISIONS: { value: DatePrecision; label: () => string }[] = [
|
||||
{ value: 'DAY', label: m.date_precision_option_day },
|
||||
{ value: 'MONTH', label: m.date_precision_option_month },
|
||||
{ value: 'SEASON', label: m.date_precision_option_season },
|
||||
{ value: 'YEAR', label: m.date_precision_option_year },
|
||||
{ value: 'RANGE', label: m.date_precision_option_range },
|
||||
{ value: 'APPROX', label: m.date_precision_option_approx },
|
||||
{ value: 'UNKNOWN', label: m.date_precision_option_unknown }
|
||||
];
|
||||
|
||||
const showEndDate = $derived(precision === 'RANGE');
|
||||
|
||||
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
|
||||
// and is then user-driven. onMount runs exactly once, so this never stomps
|
||||
// the parent's dateIso on a later prop change.
|
||||
let dateDisplay = $state('');
|
||||
let dateDirty = $state(false);
|
||||
let endDisplay = $state('');
|
||||
|
||||
onMount(() => {
|
||||
const seed = dateIso || initialDateIso;
|
||||
@@ -45,6 +65,7 @@ onMount(() => {
|
||||
dateDisplay = isoToGerman(seed);
|
||||
if (!dateIso) dateIso = seed;
|
||||
}
|
||||
if (endDateIso) endDisplay = isoToGerman(endDateIso);
|
||||
});
|
||||
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
@@ -56,6 +77,12 @@ function handleDateInput(e: Event) {
|
||||
dateDirty = true;
|
||||
}
|
||||
|
||||
function handleEndDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
endDisplay = result.display;
|
||||
endDateIso = result.iso;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedDateIso;
|
||||
if (suggested && !untrack(() => dateDirty)) {
|
||||
@@ -96,6 +123,53 @@ $effect(() => {
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Datumsgenauigkeit (precision) -->
|
||||
<div data-testid="who-when-precision">
|
||||
<label for="metaDatePrecision" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_precision()}
|
||||
</label>
|
||||
<select
|
||||
id="metaDatePrecision"
|
||||
name="metaDatePrecision"
|
||||
bind:value={precision}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{#each PRECISIONS as p (p.value)}
|
||||
<option value={p.value}>{p.label()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
|
||||
<div aria-live="polite">
|
||||
{#if showEndDate}
|
||||
<div data-testid="who-when-end-date">
|
||||
<label for="metaDateEnd" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_end()}
|
||||
</label>
|
||||
<input
|
||||
id="metaDateEnd"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={endDisplay}
|
||||
oninput={handleEndDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input type="hidden" name="metaDateEnd" value={showEndDate ? endDateIso : ''} />
|
||||
|
||||
<!-- Originaltext (read-only raw cell): labelled static text, not a disabled input. -->
|
||||
{#if rawDate && rawDate.trim().length > 0}
|
||||
<div data-testid="who-when-raw">
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.date_original_label()}</p>
|
||||
<p class="font-sans text-sm text-ink">{rawDate}</p>
|
||||
<input type="hidden" name="metaDateRaw" value={rawDate} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Absender (required in upload mode — row 1, col 2) -->
|
||||
|
||||
@@ -72,3 +72,33 @@ describe('WhoWhenSection — date input behavior', () => {
|
||||
expect(label?.textContent).toContain('*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhoWhenSection — precision controls', () => {
|
||||
it('renders a labelled precision select', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
|
||||
const label = document.querySelector('label[for="metaDatePrecision"]');
|
||||
const select = document.querySelector('select#metaDatePrecision[name="metaDatePrecision"]');
|
||||
expect(label).not.toBeNull();
|
||||
expect(select).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the end-date field unless precision is RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'DAY' });
|
||||
expect(document.querySelector('input#metaDateEnd')).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals the end-date field when precision is RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'RANGE' });
|
||||
expect(document.querySelector('input#metaDateEnd')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the raw cell as static text (not an editable input) and escapes it', async () => {
|
||||
render(WhoWhenSection, { rawDate: '<b>Sommer</b> 1916' });
|
||||
const raw = document.querySelector('[data-testid="who-when-raw"]');
|
||||
expect(raw).not.toBeNull();
|
||||
// Verbatim shown as escaped text; no injected <b> element.
|
||||
expect(raw?.textContent).toContain('<b>Sommer</b> 1916');
|
||||
expect(raw?.querySelector('b')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
105
frontend/src/lib/shared/utils/documentDate.spec.ts
Normal file
105
frontend/src/lib/shared/utils/documentDate.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { formatDocumentDate } from './documentDate';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
// ─── Shared drift-guard fixture ─────────────────────────────────────────────
|
||||
// The same table is asserted by the Java DocumentTitleFormatter test so the two
|
||||
// label implementations cannot drift. Expected values are the German canonical
|
||||
// form (see docs/date-label-fixtures.json).
|
||||
type FixtureCase = {
|
||||
name: string;
|
||||
precision: string;
|
||||
anchor: string | null;
|
||||
end: string | null;
|
||||
raw: string | null;
|
||||
expected: string;
|
||||
};
|
||||
|
||||
const fixtures = JSON.parse(
|
||||
readFileSync(resolve(process.cwd(), '../docs/date-label-fixtures.json'), 'utf-8')
|
||||
) as { cases: FixtureCase[] };
|
||||
|
||||
describe('formatDocumentDate – shared fixture table (de)', () => {
|
||||
for (const c of fixtures.cases) {
|
||||
it(c.name, () => {
|
||||
expect(
|
||||
formatDocumentDate(
|
||||
c.anchor,
|
||||
c.precision as Parameters<typeof formatDocumentDate>[1],
|
||||
c.end,
|
||||
c.raw,
|
||||
'de'
|
||||
)
|
||||
).toBe(c.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Anti-fabrication: suppressed components never leak ──────────────────────
|
||||
|
||||
describe('formatDocumentDate – suppressed precision components', () => {
|
||||
it('YEAR of a June date renders the year only, never the month', () => {
|
||||
const label = formatDocumentDate('1916-06-15', 'YEAR');
|
||||
expect(label).toBe('1916');
|
||||
expect(label).not.toContain('Juni');
|
||||
expect(label).not.toContain('15');
|
||||
});
|
||||
|
||||
it('MONTH never renders the day-of-month', () => {
|
||||
const label = formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916');
|
||||
expect(label).toBe('Juni 1916');
|
||||
expect(label).not.toMatch(/\b1\.\s/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── i18n: localized structured label ───────────────────────────────────────
|
||||
|
||||
describe('formatDocumentDate – localization', () => {
|
||||
it('localizes the UNKNOWN label per locale', () => {
|
||||
expect(formatDocumentDate(null, 'UNKNOWN', null, '?', 'en')).toBe(
|
||||
m.date_precision_unknown(undefined, { locale: 'en' })
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the APPROX prefix per locale', () => {
|
||||
expect(formatDocumentDate('1920-01-01', 'APPROX', null, null, 'en')).toBe(
|
||||
`${m.date_precision_approx_prefix(undefined, { locale: 'en' })} 1920`
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the SEASON word per locale when raw is absent', () => {
|
||||
expect(formatDocumentDate('1916-07-01', 'SEASON', null, null, 'en')).toBe(
|
||||
`${m.date_season_summer(undefined, { locale: 'en' })} 1916`
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the SEASON word even when the raw cell is verbatim German (Decision 4)', () => {
|
||||
expect(formatDocumentDate('1916-06-01', 'SEASON', null, 'Sommer 1916', 'en')).toBe(
|
||||
`${m.date_season_summer(undefined, { locale: 'en' })} 1916`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Security: untrusted raw must never influence the structured label ───────
|
||||
|
||||
describe('formatDocumentDate – security', () => {
|
||||
it('ignores a malicious raw value for the structured label (raw is rendered separately, escaped)', () => {
|
||||
const label = formatDocumentDate(null, 'UNKNOWN', null, '<img src=x onerror=alert(1)>');
|
||||
expect(label).toBe('Datum unbekannt');
|
||||
expect(label).not.toContain('<img');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Defensive null handling ─────────────────────────────────────────────────
|
||||
|
||||
describe('formatDocumentDate – defensive null handling', () => {
|
||||
it('renders the unknown label when the anchor is null but precision is not UNKNOWN', () => {
|
||||
expect(formatDocumentDate(null, 'DAY')).toBe('Datum unbekannt');
|
||||
});
|
||||
|
||||
it('falls back to start-day only for a RANGE whose end is null', () => {
|
||||
expect(formatDocumentDate('1917-01-10', 'RANGE', null)).toBe('ab 10. Jan. 1917');
|
||||
});
|
||||
});
|
||||
159
frontend/src/lib/shared/utils/documentDate.ts
Normal file
159
frontend/src/lib/shared/utils/documentDate.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { formatDate, formatMCDate } from './date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
/**
|
||||
* Precision of a document's date — mirrors the backend {@code DatePrecision} enum
|
||||
* and the import normalizer's seven values verbatim.
|
||||
*/
|
||||
export type DatePrecision = 'DAY' | 'MONTH' | 'SEASON' | 'YEAR' | 'RANGE' | 'APPROX' | 'UNKNOWN';
|
||||
|
||||
/**
|
||||
* Renders a document date at exactly the precision the data claims — never finer.
|
||||
*
|
||||
* Delegates to the {@link formatDate}/{@link formatMCDate} helpers (so the
|
||||
* `T12:00:00` UTC-safety convention and the German Intl formatting are shared,
|
||||
* not reimplemented) and routes every localized word through Paraglide.
|
||||
*
|
||||
* The label is the SINGLE SOURCE OF TRUTH shared with the Java
|
||||
* {@code DocumentTitleFormatter}: both are asserted against
|
||||
* `docs/date-label-fixtures.json` so they cannot drift. The untrusted `raw`
|
||||
* cell is only used to derive a season word (a known German season token) — it
|
||||
* is otherwise rendered separately by the caller via Svelte default escaping,
|
||||
* never interpolated into HTML here.
|
||||
*
|
||||
* @param iso the sort/filter anchor day (`YYYY-MM-DD`), nullable for UNKNOWN rows
|
||||
* @param precision descriptive precision metadata
|
||||
* @param end the RANGE end day; null means an open-ended range
|
||||
* @param raw the verbatim spreadsheet cell, used only for the SEASON word
|
||||
* @param locale BCP 47 tag for the localized structured parts (default `de-DE`)
|
||||
*/
|
||||
export function formatDocumentDate(
|
||||
iso: string | null | undefined,
|
||||
precision: DatePrecision,
|
||||
end?: string | null,
|
||||
raw?: string | null,
|
||||
locale: string = 'de-DE'
|
||||
): string {
|
||||
if (precision === 'UNKNOWN' || !iso) {
|
||||
return m.date_precision_unknown(undefined, { locale: messageLocale(locale) });
|
||||
}
|
||||
|
||||
const year = iso.slice(0, 4);
|
||||
|
||||
switch (precision) {
|
||||
case 'DAY':
|
||||
return formatDate(iso, 'long');
|
||||
case 'MONTH':
|
||||
return monthYear(iso, locale);
|
||||
case 'SEASON':
|
||||
return seasonLabel(iso, raw, locale, year);
|
||||
case 'YEAR':
|
||||
return year;
|
||||
case 'APPROX':
|
||||
return `${m.date_precision_approx_prefix(undefined, { locale: messageLocale(locale) })} ${year}`;
|
||||
case 'RANGE':
|
||||
return rangeLabel(iso, end, locale);
|
||||
default:
|
||||
return m.date_precision_unknown(undefined, { locale: messageLocale(locale) });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── precision branches ──────────────────────────────────────────────────────
|
||||
|
||||
function monthYear(iso: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(noon(iso));
|
||||
}
|
||||
|
||||
function seasonLabel(
|
||||
iso: string,
|
||||
raw: string | null | undefined,
|
||||
locale: string,
|
||||
year: string
|
||||
): string {
|
||||
const month = Number(iso.slice(5, 7));
|
||||
// Prefer the season named in the raw cell; fall back to deriving it from the
|
||||
// anchor month. Either way the WORD is localized (Decision 4) — the verbatim
|
||||
// German raw cell is preserved separately as the visible secondary line.
|
||||
const season = seasonFromRaw(raw) ?? seasonOfMonth(month);
|
||||
return `${seasonWord(season, locale)} ${year}`;
|
||||
}
|
||||
|
||||
function rangeLabel(iso: string, end: string | null | undefined, locale: string): string {
|
||||
if (!end) {
|
||||
return `${m.date_range_open_prefix(undefined, { locale: messageLocale(locale) })} ${formatMCDate(iso, locale)}`;
|
||||
}
|
||||
if (end === iso) {
|
||||
return formatMCDate(iso, locale);
|
||||
}
|
||||
const start = noon(iso);
|
||||
const finish = noon(end);
|
||||
if (start.getFullYear() === finish.getFullYear()) {
|
||||
return sameYearRange(end, start, finish, locale);
|
||||
}
|
||||
return `${formatMCDate(iso, locale)} – ${formatMCDate(end, locale)}`;
|
||||
}
|
||||
|
||||
function sameYearRange(end: string, start: Date, finish: Date, locale: string): string {
|
||||
if (start.getMonth() === finish.getMonth()) {
|
||||
// Collapse the shared month/year: only the end carries "DD. Mon. YYYY".
|
||||
return `${start.getDate()}.–${formatMCDate(end, locale)}`;
|
||||
}
|
||||
const startNoYear = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' }).format(
|
||||
start
|
||||
);
|
||||
return `${startNoYear} – ${formatMCDate(end, locale)}`;
|
||||
}
|
||||
|
||||
// ─── season helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type Season = 'spring' | 'summer' | 'autumn' | 'winter';
|
||||
|
||||
/** Quarter buckets; matches the normalizer's representative months (4/7/10/1). */
|
||||
function seasonOfMonth(month: number): Season {
|
||||
if (month >= 3 && month <= 5) return 'spring';
|
||||
if (month >= 6 && month <= 8) return 'summer';
|
||||
if (month >= 9 && month <= 11) return 'autumn';
|
||||
return 'winter';
|
||||
}
|
||||
|
||||
function seasonWord(season: Season, locale: string): string {
|
||||
const opts = { locale: messageLocale(locale) };
|
||||
switch (season) {
|
||||
case 'spring':
|
||||
return m.date_season_spring(undefined, opts);
|
||||
case 'summer':
|
||||
return m.date_season_summer(undefined, opts);
|
||||
case 'autumn':
|
||||
return m.date_season_autumn(undefined, opts);
|
||||
case 'winter':
|
||||
return m.date_season_winter(undefined, opts);
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a German season token at the start of the raw cell to a Season, else null. */
|
||||
function seasonFromRaw(raw: string | null | undefined): Season | null {
|
||||
if (!raw) return null;
|
||||
const token = raw.trim().split(/\s+/)[0].toLowerCase();
|
||||
const byToken: Record<string, Season> = {
|
||||
frühling: 'spring',
|
||||
frühjahr: 'spring',
|
||||
sommer: 'summer',
|
||||
herbst: 'autumn',
|
||||
winter: 'winter'
|
||||
};
|
||||
return byToken[token] ?? null;
|
||||
}
|
||||
|
||||
// ─── shared utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function noon(iso: string): Date {
|
||||
return new Date(iso + 'T12:00:00');
|
||||
}
|
||||
|
||||
/** Paraglide expects a registered locale tag; map `de-DE` → `de` etc. */
|
||||
function messageLocale(locale: string): 'de' | 'en' | 'es' {
|
||||
const base = locale.slice(0, 2);
|
||||
if (base === 'en') return 'en';
|
||||
if (base === 'es') return 'es';
|
||||
return 'de';
|
||||
}
|
||||
Reference in New Issue
Block a user